diff --git a/go-shared/pkg/utils/substitute_suffix.go b/go-shared/pkg/utils/substitute_suffix.go new file mode 100644 index 000000000..b06276203 --- /dev/null +++ b/go-shared/pkg/utils/substitute_suffix.go @@ -0,0 +1,13 @@ +package utils + +import "strings" + +func SubstituteSuffix(s string, suffixesToReplace []string, replacement string) string { + for _, suffix := range suffixesToReplace { + if strings.HasSuffix(s, suffix) { + s = strings.TrimSuffix(s, suffix) + replacement + break + } + } + return s +} diff --git a/go-shared/pkg/utils/substitute_suffix_test.go b/go-shared/pkg/utils/substitute_suffix_test.go new file mode 100644 index 000000000..d9b14bb9f --- /dev/null +++ b/go-shared/pkg/utils/substitute_suffix_test.go @@ -0,0 +1,59 @@ +package utils + +import "testing" + +func TestSubstituteSuffix(t *testing.T) { + type args struct { + s string + replacement string + suffixesToReplace []string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no old domains", + args: args{ + s: "test@example.com", + replacement: "example.org", + }, + want: "test@example.com", + }, + { + name: "single old domain", + args: args{ + s: "test@example.com", + suffixesToReplace: []string{"example.com"}, + replacement: "example.org", + }, + want: "test@example.org", + }, + { + name: "multiple old domains", + args: args{ + s: "test@example.com", + suffixesToReplace: []string{"example.com", "example.net"}, + replacement: "example.org", + }, + want: "test@example.org", + }, + { + name: "no match", + args: args{ + s: "test@example.com", + suffixesToReplace: []string{"example.net"}, + replacement: "example.org", + }, + want: "test@example.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SubstituteSuffix(tt.args.s, tt.args.suffixesToReplace, tt.args.replacement); got != tt.want { + t.Errorf("SubstituteSuffix() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sherlock/.mockery.yaml b/sherlock/.mockery.yaml index 4750de489..9383c0b57 100644 --- a/sherlock/.mockery.yaml +++ b/sherlock/.mockery.yaml @@ -54,3 +54,13 @@ packages: github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication/gha_oidc: interfaces: mockableVerifier: + github.com/broadinstitute/sherlock/sherlock/internal/role_propagation: + interfaces: + propagator: + github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user: + interfaces: + Identifier: + Fields: + github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines: + interfaces: + PropagationEngine: diff --git a/sherlock/config/default_config.yaml b/sherlock/config/default_config.yaml index 5c6ed1f93..6a9897a46 100644 --- a/sherlock/config/default_config.yaml +++ b/sherlock/config/default_config.yaml @@ -245,3 +245,84 @@ beehive: self: overrideEmail: overrideSubjectID: + +rolePropagation: + # If true, Sherlock's boot process will include configuring the standard array of role propagators + # (configured in the "propagators" section below). If false, the array of propagators will typically + # be empty, so propagation will have no effect. + enable: false + # If true, roles will be propagated asynchronously after requests to the role and role assignment + # endpoints. If false, roles will be propagated synchronously before those requests are completed. + asynchronous: true + # The duration that an individual propagator should be able to run for a single role before being + # forcibly shut down with an error. This can be overridden for individual propagators by specifying + # a "timeout" field in their configuration. + defaultTimeout: 5m + # The duration after which Sherlock will consider a role's propagation to be stale and in need of + # re-propagation. This measures against the end of the last propagation (regardless of success). + driftAlignmentStaleThreshold: 5m + propagators: + + devFirecloudGroup: + enable: false + # The domain of the Google Workspace, assumed to be the email domain of all members. This should + # not contain a leading "@". + workspaceDomain: "test.firecloud.org" + # Suffixes of Sherlock users' emails that should be swapped out with "@"+workspaceDomain to match + # Sherlock users to Google Workspace users. This must contain a "@". + userEmailSuffixesToReplace: + - "@broadinstitute.org" + + qaFirecloudGroup: + enable: false + # The domain of the Google Workspace, assumed to be the email domain of all members. This should + # not contain a leading "@". + workspaceDomain: "qa.firecloud.org" + # Suffixes of Sherlock users' emails that should be swapped out with "@"+workspaceDomain to match + # Sherlock users to Google Workspace users. This must contain a "@". + userEmailSuffixesToReplace: + - "@broadinstitute.org" + + prodFirecloudGroup: + enable: false + # The domain of the Google Workspace, assumed to be the email domain of all members. This should + # not contain a leading "@". + workspaceDomain: "firecloud.org" + # Suffixes of Sherlock users' emails that should be swapped out with "@"+workspaceDomain to match + # Sherlock users to Google Workspace users. This must contain a "@". + userEmailSuffixesToReplace: + - "@broadinstitute.org" + + devAzureGroup: + enable: false + # The client ID of the Azure AD app to use for authentication. + clientID: + # The UUID of the Azure AD tenant to work with. + tenantID: fad90753-2022-4456-9b0a-c7e5b934e408 # azure.dev.envs-terra.bio + # The path on disk that Sherlock should expect to find a token for federated workload identity. + tokenFilePath: /azure-federation/projected-ksa-token.jwt + # The suffix of all member emails. This can be thought of as a filter for what Azure users Sherlock + # will attempt to propagate roles to. This may contain a "@" (especially useful for "#EXT#@" emails); + # if it does, then the userEmailSuffixesToReplace must as well. + memberEmailSuffix: "_broadinstitute.org#EXT#@devazureterra.onmicrosoft.com" + # Suffixes of Sherlock users' emails that should be swapped out with the memberEmailSuffix to match + # Sherlock users to Azure Entra ID users. + userEmailSuffixesToReplace: + - "@broadinstitute.org" + + prodAzureGroup: + enable: false + # The client ID of the Azure AD app to use for authentication. + clientID: + # The UUID of the Azure AD tenant to work with. + tenantID: 66bb90ac-8857-4a8a-aa0a-be2186dfa5f9 # firecloud.org + # The path on disk that Sherlock should expect to find a token for federated workload identity. + tokenFilePath: /azure-federation/projected-ksa-token.jwt + # The suffix of all member emails. This can be thought of as a filter for what Azure users Sherlock + # will attempt to propagate roles to. This may contain a "@" (especially useful for "#EXT#@" emails); + # if it does, then the userEmailSuffixesToReplace must as well. + memberEmailSuffix: "_broadinstitute.org#EXT#@terraazureprod.onmicrosoft.com" + # Suffixes of Sherlock users' emails that should be swapped out with the memberEmailSuffix to match + # Sherlock users to Azure Entra ID users. + userEmailSuffixesToReplace: + - "@broadinstitute.org" diff --git a/sherlock/config/test_config.yaml b/sherlock/config/test_config.yaml index 5ee00ae55..e7c52573c 100644 --- a/sherlock/config/test_config.yaml +++ b/sherlock/config/test_config.yaml @@ -53,3 +53,23 @@ model: self: overrideEmail: sherlock-test@broadinstitute.org overrideSubjectID: sherlock-test + +rolePropagation: + asynchronous: false + propagators: + devFirecloudGroupTestDisabled: + enable: false + workspaceDomain: test.firecloud.org + + devFirecloudGroupTestDefault: + enable: true + workspaceDomain: test.firecloud.org + + devFirecloudGroupTestConfig: + enable: true + workspaceDomain: test.firecloud.org + timeout: 10s + userEmailDomainsToReplace: + - broadinstitute.org + toleratedUsers: + - email: tolerated@test.firecloud.org diff --git a/sherlock/db/migrations/000089_role_propagated_at.down.sql b/sherlock/db/migrations/000089_role_propagated_at.down.sql new file mode 100644 index 000000000..2db74e22c --- /dev/null +++ b/sherlock/db/migrations/000089_role_propagated_at.down.sql @@ -0,0 +1,2 @@ +alter table roles + drop column if exists propagated_at; diff --git a/sherlock/db/migrations/000089_role_propagated_at.up.sql b/sherlock/db/migrations/000089_role_propagated_at.up.sql new file mode 100644 index 000000000..4cd736993 --- /dev/null +++ b/sherlock/db/migrations/000089_role_propagated_at.up.sql @@ -0,0 +1,2 @@ +alter table roles + add column if not exists propagated_at timestamp with time zone; diff --git a/sherlock/db/migrations/000090_role_qa_prod_group_fields.down.sql b/sherlock/db/migrations/000090_role_qa_prod_group_fields.down.sql new file mode 100644 index 000000000..67d4fc8e9 --- /dev/null +++ b/sherlock/db/migrations/000090_role_qa_prod_group_fields.down.sql @@ -0,0 +1,32 @@ +drop index if exists roles_grants_qa_firecloud_group_unique; + +alter table roles + drop column if exists grants_qa_firecloud_group; + +alter table role_operations + drop column if exists from_grants_qa_firecloud_group; + +alter table role_operations + drop column if exists to_grants_qa_firecloud_group; + +drop index if exists roles_grants_prod_firecloud_group_unique; + +alter table roles + drop column if exists grants_prod_firecloud_group; + +alter table role_operations + drop column if exists from_grants_prod_firecloud_group; + +alter table role_operations + drop column if exists to_grants_prod_firecloud_group; + +drop index if exists roles_grants_prod_azure_group_unique; + +alter table roles + drop column if exists grants_prod_azure_group; + +alter table role_operations + drop column if exists from_grants_prod_azure_group; + +alter table role_operations + drop column if exists to_grants_prod_azure_group; diff --git a/sherlock/db/migrations/000090_role_qa_prod_group_fields.up.sql b/sherlock/db/migrations/000090_role_qa_prod_group_fields.up.sql new file mode 100644 index 000000000..0f7054a20 --- /dev/null +++ b/sherlock/db/migrations/000090_role_qa_prod_group_fields.up.sql @@ -0,0 +1,38 @@ +alter table roles + add column if not exists grants_qa_firecloud_group text; + +create unique index if not exists roles_grants_qa_firecloud_group_unique + on roles (grants_qa_firecloud_group) + where deleted_at is null and grants_qa_firecloud_group is not null and grants_qa_firecloud_group != ''; + +alter table role_operations + add column if not exists from_grants_qa_firecloud_group text; + +alter table role_operations + add column if not exists to_grants_qa_firecloud_group text; + +alter table roles + add column if not exists grants_prod_firecloud_group text; + +create unique index if not exists roles_grants_prod_firecloud_group_unique + on roles (grants_prod_firecloud_group) + where deleted_at is null and grants_prod_firecloud_group is not null and grants_prod_firecloud_group != ''; + +alter table role_operations + add column if not exists from_grants_prod_firecloud_group text; + +alter table role_operations + add column if not exists to_grants_prod_firecloud_group text; + +alter table roles + add column if not exists grants_prod_azure_group text; + +create unique index if not exists roles_grants_prod_azure_group_unique + on roles (grants_prod_azure_group) + where deleted_at is null and grants_prod_azure_group is not null and grants_prod_azure_group != ''; + +alter table role_operations + add column if not exists from_grants_prod_azure_group text; + +alter table role_operations + add column if not exists to_grants_prod_azure_group text; diff --git a/sherlock/go.mod b/sherlock/go.mod index e10830efe..dcc5e1291 100644 --- a/sherlock/go.mod +++ b/sherlock/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( cloud.google.com/go/cloudsqlconn v1.10.1 contrib.go.opencensus.io/exporter/prometheus v0.4.2 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 github.com/PagerDuty/go-pagerduty v1.8.0 github.com/broadinstitute/sherlock/go-shared v0.0.0 github.com/coreos/go-oidc v2.2.1+incompatible @@ -18,6 +19,7 @@ require ( github.com/jackc/pgx/v5 v5.6.0 github.com/jinzhu/copier v0.4.0 github.com/knadh/koanf v1.5.0 + github.com/microsoftgraph/msgraph-sdk-go v1.44.0 github.com/pact-foundation/pact-go/v2 v2.0.5 github.com/rs/zerolog v1.33.0 github.com/slack-go/slack v0.13.0 @@ -42,11 +44,15 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect cloud.google.com/go/compute/metadata v0.3.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cjlapao/common-go v0.0.39 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -67,6 +73,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -88,18 +95,28 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/pretty v0.3.1 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/microsoft/kiota-abstractions-go v1.6.0 // indirect + github.com/microsoft/kiota-authentication-azure-go v1.0.2 // indirect + github.com/microsoft/kiota-http-go v1.3.1 // indirect + github.com/microsoft/kiota-serialization-form-go v1.0.0 // indirect + github.com/microsoft/kiota-serialization-json-go v1.0.7 // indirect + github.com/microsoft/kiota-serialization-multipart-go v1.0.0 // indirect + github.com/microsoft/kiota-serialization-text-go v1.0.0 // indirect + github.com/microsoftgraph/msgraph-sdk-go-core v1.1.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021 // indirect github.com/prometheus/client_golang v1.14.0 // indirect @@ -111,6 +128,7 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/std-uritemplate/std-uritemplate/go v0.0.55 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect diff --git a/sherlock/go.sum b/sherlock/go.sum index 65f0e871d..5d0318648 100644 --- a/sherlock/go.sum +++ b/sherlock/go.sum @@ -43,8 +43,16 @@ contrib.go.opencensus.io/exporter/prometheus v0.4.2/go.mod h1:dvEHbiKmgvbr5pjaF9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 h1:FDif4R1+UUR+00q6wquyX90K7A8dN+R5E8GEadoP7sU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2/go.mod h1:aiYBYui4BJ/BJCAIKs92XiPyQfTaBWqvHujDwKb6CBU= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -91,6 +99,8 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cjlapao/common-go v0.0.39 h1:bAAUrj2B9v0kMzbAOhzjSmiyDy+rd56r2sy7oEiQLlA= +github.com/cjlapao/common-go v0.0.39/go.mod h1:M3dzazLjTjEtZJbbxoA5ZDiGCiHmpwqW9l4UWaddwOA= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= @@ -112,6 +122,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= @@ -199,6 +211,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -394,6 +408,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -424,6 +440,24 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/microsoft/go-mssqldb v1.7.1 h1:KU/g8aWeM3Hx7IMOFpiwYiUkU+9zeISb4+tx3ScVfsM= github.com/microsoft/go-mssqldb v1.7.1/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/microsoft/kiota-abstractions-go v1.6.0 h1:qbGBNMU0/o5myKbikCBXJFohVCFrrpx2cO15Rta2WyA= +github.com/microsoft/kiota-abstractions-go v1.6.0/go.mod h1:7YH20ZbRWXGfHSSvdHkdztzgCB9mRdtFx13+hrYIEpo= +github.com/microsoft/kiota-authentication-azure-go v1.0.2 h1:tClGeyFZJ+4Bakf8u0euPM4wqy4ethycdOgx3jyH3pI= +github.com/microsoft/kiota-authentication-azure-go v1.0.2/go.mod h1:aTcti0bUJEcq7kBfQG4Sr4ElvRNuaalXcFEu4iEyQ6M= +github.com/microsoft/kiota-http-go v1.3.1 h1:S+ZDxE7Pc/Z06hbfqpFHkoq5xiC8/7d12iNovcgl+7o= +github.com/microsoft/kiota-http-go v1.3.1/go.mod h1:4QjB+as08swnZXZLx5I+ZHZ8U/tVy7Zu49RNTmWgw48= +github.com/microsoft/kiota-serialization-form-go v1.0.0 h1:UNdrkMnLFqUCccQZerKjblsyVgifS11b3WCx+eFEsAI= +github.com/microsoft/kiota-serialization-form-go v1.0.0/go.mod h1:h4mQOO6KVTNciMF6azi1J9QB19ujSw3ULKcSNyXXOMA= +github.com/microsoft/kiota-serialization-json-go v1.0.7 h1:yMbckSTPrjZdM4EMXgzLZSA3CtDaUBI350u0VoYRz7Y= +github.com/microsoft/kiota-serialization-json-go v1.0.7/go.mod h1:1krrY7DYl3ivPIzl4xTaBpew6akYNa8/Tal8g+kb0cc= +github.com/microsoft/kiota-serialization-multipart-go v1.0.0 h1:3O5sb5Zj+moLBiJympbXNaeV07K0d46IfuEd5v9+pBs= +github.com/microsoft/kiota-serialization-multipart-go v1.0.0/go.mod h1:yauLeBTpANk4L03XD985akNysG24SnRJGaveZf+p4so= +github.com/microsoft/kiota-serialization-text-go v1.0.0 h1:XOaRhAXy+g8ZVpcq7x7a0jlETWnWrEum0RhmbYrTFnA= +github.com/microsoft/kiota-serialization-text-go v1.0.0/go.mod h1:sM1/C6ecnQ7IquQOGUrUldaO5wj+9+v7G2W3sQ3fy6M= +github.com/microsoftgraph/msgraph-sdk-go v1.44.0 h1:NN3nWtK/hSMHUN1ECRdFAqMvNzyx+ZCkXZ62q5btnLI= +github.com/microsoftgraph/msgraph-sdk-go v1.44.0/go.mod h1:MSMgjuMPKAsIz8XfH5l+e781fkWjUxc1XXhb2eoSdc0= +github.com/microsoftgraph/msgraph-sdk-go-core v1.1.0 h1:NB7c/n4Knj+TLaLfjsahhSqoUqoN/CtyNB0XIe/nJnM= +github.com/microsoftgraph/msgraph-sdk-go-core v1.1.0/go.mod h1:M3w/5IFJ1u/DpwOyjsjNSVEA43y1rLOeX58suyfBhGk= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -471,6 +505,8 @@ github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAv github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -540,6 +576,8 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/std-uritemplate/std-uritemplate/go v0.0.55 h1:muSH037g97K7U2f94G9LUuE8tZlJsoSSrPsO9V281WY= +github.com/std-uritemplate/std-uritemplate/go v0.0.55/go.mod h1:rG/bqh/ThY4xE5de7Rap3vaDkYUT76B0GPJ0loYeTTc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -775,6 +813,7 @@ golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/sherlock/internal/api/sherlock/role_assignments_v3_create.go b/sherlock/internal/api/sherlock/role_assignments_v3_create.go index 7775b4b1d..b1556417d 100644 --- a/sherlock/internal/api/sherlock/role_assignments_v3_create.go +++ b/sherlock/internal/api/sherlock/role_assignments_v3_create.go @@ -5,6 +5,7 @@ import ( "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication" "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation" "github.com/creasty/defaults" "github.com/gin-gonic/gin" "gorm.io/gorm/clause" @@ -16,6 +17,7 @@ import ( // @summary Create a RoleAssignment // @description Create the RoleAssignment between a given Role and User. // @description Non-super-admins may only mutate RoleAssignments for themselves, only for roles they can break-glass into, and only with an expiry no further than the role's default break-glass duration in the future. +// @description Propagation will be triggered after this operation. // @tags RoleAssignments // @produce json // @param role-selector path string true "The selector of the Role, which can be either the numeric ID or the name" @@ -80,4 +82,6 @@ func roleAssignmentsV3Create(ctx *gin.Context) { } ctx.JSON(http.StatusCreated, roleAssignmentFromModel(toCreate)) + + role_propagation.DoOnDemandPropagation(ctx, db, role.ID) } diff --git a/sherlock/internal/api/sherlock/role_assignments_v3_delete.go b/sherlock/internal/api/sherlock/role_assignments_v3_delete.go index 2faea9ce1..75abfa51a 100644 --- a/sherlock/internal/api/sherlock/role_assignments_v3_delete.go +++ b/sherlock/internal/api/sherlock/role_assignments_v3_delete.go @@ -4,6 +4,7 @@ import ( "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication" "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation" "github.com/gin-gonic/gin" "gorm.io/gorm/clause" "net/http" @@ -14,6 +15,7 @@ import ( // @summary Delete a RoleAssignment // @description Delete the RoleAssignment between a given Role and User. // @description Non-super-admins may only mutate RoleAssignments for themselves, only for roles they can break-glass into, and only with an expiry no further than the role's default break-glass duration in the future. +// @description Propagation will be triggered after this operation. // @tags RoleAssignments // @produce json // @param role-selector path string true "The selector of the Role, which can be either the numeric ID or the name" @@ -63,4 +65,6 @@ func roleAssignmentsV3Delete(ctx *gin.Context) { } ctx.JSON(http.StatusOK, roleAssignmentFromModel(result)) + + role_propagation.DoOnDemandPropagation(ctx, db, role.ID) } diff --git a/sherlock/internal/api/sherlock/role_assignments_v3_edit.go b/sherlock/internal/api/sherlock/role_assignments_v3_edit.go index fcbed1cbb..814605afb 100644 --- a/sherlock/internal/api/sherlock/role_assignments_v3_edit.go +++ b/sherlock/internal/api/sherlock/role_assignments_v3_edit.go @@ -5,6 +5,7 @@ import ( "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication" "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation" "github.com/gin-gonic/gin" "gorm.io/gorm/clause" "net/http" @@ -15,6 +16,7 @@ import ( // @summary Edit a RoleAssignment // @description Edit the RoleAssignment between a given Role and User. // @description Non-super-admins may only mutate RoleAssignments for themselves, only for roles they can break-glass into, and only with an expiry no further than the role's default break-glass duration in the future. +// @description Propagation will be triggered after this operation. // @tags RoleAssignments // @produce json // @param role-selector path string true "The selector of the Role, which can be either the numeric ID or the name" @@ -74,4 +76,6 @@ func roleAssignmentsV3Edit(ctx *gin.Context) { } ctx.JSON(http.StatusOK, roleAssignmentFromModel(toEdit)) + + role_propagation.DoOnDemandPropagation(ctx, db, role.ID) } diff --git a/sherlock/internal/api/sherlock/roles_v3.go b/sherlock/internal/api/sherlock/roles_v3.go index 835cf91ea..cd47a5c3c 100644 --- a/sherlock/internal/api/sherlock/roles_v3.go +++ b/sherlock/internal/api/sherlock/roles_v3.go @@ -20,7 +20,10 @@ type RoleV3Edit struct { DefaultGlassBreakDuration *Duration `json:"defaultGlassBreakDuration,omitempty" swaggertype:"string" form:"defaultGlassBreakDuration"` GrantsSherlockSuperAdmin *bool `json:"grantsSherlockSuperAdmin,omitempty" form:"grantsSherlockSuperAdmin"` GrantsDevFirecloudGroup *string `json:"grantsDevFirecloudGroup,omitempty" form:"grantsDevFirecloudGroup"` + GrantsQaFirecloudGroup *string `json:"grantsQaFirecloudGroup,omitempty" form:"grantsQaFirecloudGroup"` + GrantsProdFirecloudGroup *string `json:"grantsProdFirecloudGroup,omitempty" form:"grantsProdFirecloudGroup"` GrantsDevAzureGroup *string `json:"grantsDevAzureGroup,omitempty" form:"grantsDevAzureGroup"` + GrantsProdAzureGroup *string `json:"grantsProdAzureGroup,omitempty" form:"grantsProdAzureGroup"` } func (r RoleV3) toModel() models.Role { @@ -32,7 +35,10 @@ func (r RoleV3) toModel() models.Role { CanBeGlassBrokenByRoleID: r.CanBeGlassBrokenByRole, GrantsSherlockSuperAdmin: r.GrantsSherlockSuperAdmin, GrantsDevFirecloudGroup: r.GrantsDevFirecloudGroup, + GrantsQaFirecloudGroup: r.GrantsQaFirecloudGroup, + GrantsProdFirecloudGroup: r.GrantsProdFirecloudGroup, GrantsDevAzureGroup: r.GrantsDevAzureGroup, + GrantsProdAzureGroup: r.GrantsProdAzureGroup, }, } if r.DefaultGlassBreakDuration != nil { @@ -58,7 +64,10 @@ func roleFromModel(model models.Role) RoleV3 { }, model.DefaultGlassBreakDuration), GrantsSherlockSuperAdmin: model.GrantsSherlockSuperAdmin, GrantsDevFirecloudGroup: model.GrantsDevFirecloudGroup, + GrantsQaFirecloudGroup: model.GrantsQaFirecloudGroup, + GrantsProdFirecloudGroup: model.GrantsProdFirecloudGroup, GrantsDevAzureGroup: model.GrantsDevAzureGroup, + GrantsProdAzureGroup: model.GrantsProdAzureGroup, }, } if len(model.Assignments) > 0 { diff --git a/sherlock/internal/api/sherlock/roles_v3_create.go b/sherlock/internal/api/sherlock/roles_v3_create.go index 83b532787..948950574 100644 --- a/sherlock/internal/api/sherlock/roles_v3_create.go +++ b/sherlock/internal/api/sherlock/roles_v3_create.go @@ -5,6 +5,7 @@ import ( "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication" "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation" "github.com/gin-gonic/gin" "net/http" ) @@ -14,6 +15,7 @@ import ( // @summary Create a Role // @description Create an individual Role with no one assigned to it. // @description Only super-admins may mutate Roles. +// @description Propagation will be triggered after this operation. // @tags Roles // @produce json // @param role body RoleV3Edit true "The initial fields the Role should have set" @@ -44,4 +46,6 @@ func rolesV3Create(ctx *gin.Context) { } ctx.JSON(http.StatusCreated, roleFromModel(toCreate)) + + role_propagation.DoOnDemandPropagation(ctx, db, toCreate.ID) } diff --git a/sherlock/internal/api/sherlock/roles_v3_delete.go b/sherlock/internal/api/sherlock/roles_v3_delete.go index eab5879d3..a61ebceb1 100644 --- a/sherlock/internal/api/sherlock/roles_v3_delete.go +++ b/sherlock/internal/api/sherlock/roles_v3_delete.go @@ -14,6 +14,7 @@ import ( // @summary Delete a Role // @description Delete an individual Role. // @description Only super-admins may mutate Roles. +// @description Propagation will NOT be triggered after this operation -- the grants will become un-managed by Sherlock and left as-is. Remove role assignments first to remove users from grants. // @tags Roles // @produce json // @param selector path string true "The selector of the Role, which can be either the numeric ID or the name" diff --git a/sherlock/internal/api/sherlock/roles_v3_edit.go b/sherlock/internal/api/sherlock/roles_v3_edit.go index 897f1b911..c8699b728 100644 --- a/sherlock/internal/api/sherlock/roles_v3_edit.go +++ b/sherlock/internal/api/sherlock/roles_v3_edit.go @@ -5,6 +5,7 @@ import ( "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication" "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation" "github.com/gin-gonic/gin" "gorm.io/gorm/clause" "net/http" @@ -15,6 +16,7 @@ import ( // @summary Edit a Role // @description Edit an individual Role. // @description Only super-admins may mutate Roles. +// @description Propagation will be triggered after this operation. // @tags Roles // @produce json // @param selector path string true "The selector of the Role, which can be either the numeric ID or the name" @@ -53,4 +55,6 @@ func rolesV3Edit(ctx *gin.Context) { } ctx.JSON(http.StatusOK, roleFromModel(toEdit)) + + role_propagation.DoOnDemandPropagation(ctx, db, toEdit.ID) } diff --git a/sherlock/internal/api/sherlock/roles_v3_edit_test.go b/sherlock/internal/api/sherlock/roles_v3_edit_test.go index eccb1ec95..d8b3eb6fd 100644 --- a/sherlock/internal/api/sherlock/roles_v3_edit_test.go +++ b/sherlock/internal/api/sherlock/roles_v3_edit_test.go @@ -65,3 +65,14 @@ func (s *handlerSuite) TestRolesV3Edit() { s.Equal(http.StatusOK, code) s.Equal("some-new-role-name", *got.Name) } + +func (s *handlerSuite) TestRolesV3Edit_wipeUuid() { + role := s.TestData.Role_TerraSuitableEngineer() + var got RoleV3 + code := s.HandleRequest( + s.NewSuperAdminRequest("PATCH", "/api/roles/v3/"+*role.Name, gin.H{ + "grantsDevAzureGroup": "", + }), + &got) + s.Equal(http.StatusOK, code) +} diff --git a/sherlock/internal/api/sherlock/roles_v3_test.go b/sherlock/internal/api/sherlock/roles_v3_test.go index 6310c32a7..6b99ad8d1 100644 --- a/sherlock/internal/api/sherlock/roles_v3_test.go +++ b/sherlock/internal/api/sherlock/roles_v3_test.go @@ -21,7 +21,10 @@ func (s *handlerSuite) Test_roleFromModel() { DefaultGlassBreakDuration: nil, GrantsSherlockSuperAdmin: model.GrantsSherlockSuperAdmin, GrantsDevFirecloudGroup: model.GrantsDevFirecloudGroup, + GrantsQaFirecloudGroup: model.GrantsQaFirecloudGroup, + GrantsProdFirecloudGroup: model.GrantsProdFirecloudGroup, GrantsDevAzureGroup: model.GrantsDevAzureGroup, + GrantsProdAzureGroup: model.GrantsProdAzureGroup, }, } role := roleFromModel(model) diff --git a/sherlock/internal/boot/application.go b/sherlock/internal/boot/application.go index 707e06474..403e2aa66 100644 --- a/sherlock/internal/boot/application.go +++ b/sherlock/internal/boot/application.go @@ -12,6 +12,7 @@ import ( "github.com/broadinstitute/sherlock/sherlock/internal/metrics" "github.com/broadinstitute/sherlock/sherlock/internal/middleware/authentication/gha_oidc" "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation" "github.com/broadinstitute/sherlock/sherlock/internal/suitability_loader" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" @@ -120,6 +121,14 @@ func (a *Application) Start() { } } + if config.Config.Bool("rolePropagation.enable") { + log.Info().Msgf("BOOT | initializing role propagators...") + if err = role_propagation.Init(ctx); err != nil { + log.Fatal().Err(err).Msgf("role_propagation.Init() error") + } + go role_propagation.KeepPropagatingStale(ctx, a.gormDB) + } + log.Info().Msgf("BOOT | building Gin router...") gin.SetMode(gin.ReleaseMode) // gin.DebugMode can help resolve routing issues a.server = &http.Server{ diff --git a/sherlock/internal/models/advisory_locks/advisory_locks.go b/sherlock/internal/models/advisory_locks/advisory_locks.go new file mode 100644 index 000000000..d90700312 --- /dev/null +++ b/sherlock/internal/models/advisory_locks/advisory_locks.go @@ -0,0 +1,29 @@ +// Package advisory_locks offers constants to differentiate PostgreSQL advisory locks that Sherlock uses. +// The constants here are meant to be used as "key1" arguments to the various advisory lock functions [1]. +// Each constant documents what the "key2" argument should be. +// +// Note that it is critical that the constants in this package not be renumbered or removed, as that would +// be a breaking change for how Sherlock uses its database (would cause problems with old replicas upon +// deployment). +// +// [1]: https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +package advisory_locks + +// We disable unused detection here because `none` is intentionally unused. We have to put these comments +// on the entire const block for them to be parsed correctly. +// +//nolint:unused +//goland:noinspection GoUnusedConst +const ( + // none is a placeholder that should never be used. It exists so that the actual export constants + // begin at 1, not 0. This helps limit blast radius if we accidentally use an unset integer as "key1", + // because then at least we won't be conflicting with any correct usages. + none int = iota + + // ROLE_PROPAGATION locks models.Role records for propagation to cloud providers. The "key2" argument + // should be the ID of the models.Role. + // + // This lock exists so that we don't try to propagate the same models.Role concurrently. This lock + // should be acquired before determining if a models.Role should be propagated. + ROLE_PROPAGATION +) diff --git a/sherlock/internal/models/advisory_locks/advisory_locks_test.go b/sherlock/internal/models/advisory_locks/advisory_locks_test.go new file mode 100644 index 000000000..1bd18e01c --- /dev/null +++ b/sherlock/internal/models/advisory_locks/advisory_locks_test.go @@ -0,0 +1,17 @@ +package advisory_locks + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +// These tests exist because these consts are used as integers in the database -- if they were to +// change, that would be breaking. + +func Test_none(t *testing.T) { + assert.Equal(t, 0, none) +} + +func Test_ROLE_PROPAGATION(t *testing.T) { + assert.Equal(t, 1, ROLE_PROPAGATION) +} diff --git a/sherlock/internal/models/role.go b/sherlock/internal/models/role.go index de6ab697d..f6b182825 100644 --- a/sherlock/internal/models/role.go +++ b/sherlock/internal/models/role.go @@ -1,9 +1,12 @@ package models import ( + "database/sql" "fmt" + "github.com/broadinstitute/sherlock/sherlock/internal/models/advisory_locks" "github.com/jinzhu/copier" "gorm.io/gorm" + "time" ) type RoleFields struct { @@ -28,16 +31,30 @@ type RoleFields struct { GrantsSherlockSuperAdmin *bool // GrantsDevFirecloudGroup, when not null, indicates that a User with an unsuspended RoleAssignment to this - // Role should have their Firecloud account (if they have one) added to this group. + // Role should have their dev Firecloud account (if they have one) added to this group. GrantsDevFirecloudGroup *string + // GrantsQaFirecloudGroup, when not null, indicates that a User with an unsuspended RoleAssignment to this + // Role should have their qa Firecloud account (if they have one) added to this group. + GrantsQaFirecloudGroup *string + // GrantsProdFirecloudGroup, when not null, indicates that a User with an unsuspended RoleAssignment to this + // Role should have their prod Firecloud account (if they have one) added to this group. + GrantsProdFirecloudGroup *string + // GrantsDevAzureGroup, when not null, indicates that a User with an unsuspended RoleAssignment to this Role // should have their Azure account (if they have one) added to this group. GrantsDevAzureGroup *string + // GrantsProdAzureGroup, when not null, indicates that a User with an unsuspended RoleAssignment to this Role + // should have their Azure account (if they have one) added to this group. + GrantsProdAzureGroup *string } type Role struct { gorm.Model + // PropagatedAt stores the last time that this Role's grants were propagated to cloud providers. See + // the role_propagation package for more information. + PropagatedAt sql.NullTime + // Assignments lists User records who have this Role. A RoleAssignment can potentially be suspended, // which indicates that the User should not presently have any access commensurate with the Role. // @@ -86,6 +103,53 @@ func ReadRoleScope(db *gorm.DB) *gorm.DB { Preload("CanBeGlassBrokenByRole") } +func (r *Role) AssignmentsMap() map[uint]RoleAssignment { + roleAssignments := make(map[uint]RoleAssignment) + for _, ra := range r.Assignments { + if ra != nil && ra.UserID != 0 { + roleAssignments[ra.UserID] = *ra + } + } + return roleAssignments +} + +// WaitPropagationLock blocks until a propagation lock can be acquired for the Role. This function +// is only safe to call from a transaction. The lock will be released at the end of the transaction. +// +// See https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +func (r *Role) WaitPropagationLock(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?, ?)", advisory_locks.ROLE_PROPAGATION, r.ID).Error; err != nil { + return fmt.Errorf("failed to lock Role %d for propagation: %w", r.ID, err) + } + return nil +} + +// TryPropagationLock attempts to acquire a propagation lock for the Role. It returns a boolean for +// whether the lock was obtained; it does not block. This function is only safe to call from a +// transaction. The lock will be released at the end of the transaction. +// +// See https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS +func (r *Role) TryPropagationLock(tx *gorm.DB) (bool, error) { + var locked bool + if err := tx.Raw("SELECT pg_try_advisory_xact_lock(?, ?)", advisory_locks.ROLE_PROPAGATION, r.ID).Scan(&locked).Error; err != nil { + return false, fmt.Errorf("failed to try lock Role %d for propagation: %w", r.ID, err) + } + return locked, nil +} + +// UpdatePropagatedAt sets the Role's PropagatedAt field to the current time without triggering any +// hooks or other Gorm behavior (like setting the gorm.Model UpdatedAt field) since we're not +// semantically making a change to the Role. +func (r *Role) UpdatePropagatedAt(tx *gorm.DB) error { + if err := tx.Model(&r).UpdateColumns(&Role{PropagatedAt: sql.NullTime{ + Time: time.Now(), + Valid: true, + }}).Error; err != nil { + return err + } + return nil +} + func (r *Role) BeforeCreate(tx *gorm.DB) error { if user, err := GetCurrentUserForDB(tx); err != nil { return err diff --git a/sherlock/internal/models/role_assignment.go b/sherlock/internal/models/role_assignment.go index 0f2bcae85..32fcfd3cc 100644 --- a/sherlock/internal/models/role_assignment.go +++ b/sherlock/internal/models/role_assignment.go @@ -48,6 +48,10 @@ type RoleAssignmentOperation struct { To RoleAssignmentFields `gorm:"embedded;embeddedPrefix:to_"` } +func (ra *RoleAssignment) IsActive() bool { + return ra.Suspended != nil && !*ra.Suspended && (ra.ExpiresAt == nil || ra.ExpiresAt.After(time.Now())) +} + func (ra *RoleAssignment) errorIfForbidden(tx *gorm.DB) error { user, err := GetCurrentUserForDB(tx) if err != nil { diff --git a/sherlock/internal/models/role_assignment_test.go b/sherlock/internal/models/role_assignment_test.go index c23f38a87..396cb24bf 100644 --- a/sherlock/internal/models/role_assignment_test.go +++ b/sherlock/internal/models/role_assignment_test.go @@ -4,6 +4,8 @@ import ( "github.com/broadinstitute/sherlock/go-shared/pkg/utils" "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/jinzhu/copier" + "github.com/stretchr/testify/assert" + "testing" "time" ) @@ -279,3 +281,83 @@ func (s *modelSuite) TestRoleAssignmentInvalidMissingSuspended() { err := s.DB.Create(&roleAssignment).Error s.ErrorContains(err, "suspended") } + +func TestRoleAssignment_IsActive(t *testing.T) { + type fields struct { + Role *Role + RoleID uint + User *User + UserID uint + RoleAssignmentFields RoleAssignmentFields + previousFields RoleAssignmentFields + } + tests := []struct { + name string + fields fields + want bool + }{ + { + name: "suspension nil", + fields: fields{ + RoleAssignmentFields: RoleAssignmentFields{ + Suspended: nil, + ExpiresAt: utils.PointerTo(time.Now().Add(time.Hour)), + }, + }, + want: false, + }, + { + name: "suspended", + fields: fields{ + RoleAssignmentFields: RoleAssignmentFields{ + Suspended: utils.PointerTo(true), + ExpiresAt: utils.PointerTo(time.Now().Add(time.Hour)), + }, + }, + want: false, + }, + { + name: "expiresAt nil", + fields: fields{ + RoleAssignmentFields: RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + ExpiresAt: nil, + }, + }, + want: true, + }, + { + name: "expiresAt future", + fields: fields{ + RoleAssignmentFields: RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + ExpiresAt: utils.PointerTo(time.Now().Add(time.Hour)), + }, + }, + want: true, + }, + { + name: "expiresAt past", + fields: fields{ + RoleAssignmentFields: RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + ExpiresAt: utils.PointerTo(time.Now().Add(-time.Hour)), + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ra := &RoleAssignment{ + Role: tt.fields.Role, + RoleID: tt.fields.RoleID, + User: tt.fields.User, + UserID: tt.fields.UserID, + RoleAssignmentFields: tt.fields.RoleAssignmentFields, + previousFields: tt.fields.previousFields, + } + assert.Equalf(t, tt.want, ra.IsActive(), "IsActive()") + }) + } +} diff --git a/sherlock/internal/models/role_test.go b/sherlock/internal/models/role_test.go index 2d7f4faba..951eb8c51 100644 --- a/sherlock/internal/models/role_test.go +++ b/sherlock/internal/models/role_test.go @@ -1,9 +1,13 @@ package models import ( + "database/sql" "github.com/broadinstitute/sherlock/go-shared/pkg/utils" "github.com/broadinstitute/sherlock/sherlock/internal/errors" "github.com/jinzhu/copier" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + "testing" ) func (s *modelSuite) TestRoleUnauthorizedCreate() { @@ -157,6 +161,26 @@ func (s *modelSuite) TestRoleUniqueGrantsDevFirecloudGroup() { s.ErrorContains(err, "violates unique constraint") } +func (s *modelSuite) TestRoleUniqueGrantsQaFirecloudGroup() { + s.SetSelfSuperAdminForDB() + a := s.TestData.Role_TerraSuitableEngineer() + b := s.TestData.Role_TerraEngineer() + b.GrantsQaFirecloudGroup = a.GrantsQaFirecloudGroup + err := s.DB.Save(&b).Error + s.ErrorContains(err, "grants_qa_firecloud_group") + s.ErrorContains(err, "violates unique constraint") +} + +func (s *modelSuite) TestRoleUniqueGrantsProdFirecloudGroup() { + s.SetSelfSuperAdminForDB() + a := s.TestData.Role_TerraSuitableEngineer() + b := s.TestData.Role_TerraEngineer() + b.GrantsProdFirecloudGroup = a.GrantsProdFirecloudGroup + err := s.DB.Save(&b).Error + s.ErrorContains(err, "grants_prod_firecloud_group") + s.ErrorContains(err, "violates unique constraint") +} + func (s *modelSuite) TestRoleUniqueGrantsDevAzureGroup() { s.SetSelfSuperAdminForDB() a := s.TestData.Role_TerraSuitableEngineer() @@ -166,3 +190,67 @@ func (s *modelSuite) TestRoleUniqueGrantsDevAzureGroup() { s.ErrorContains(err, "grants_dev_azure_group") s.ErrorContains(err, "violates unique constraint") } + +func (s *modelSuite) TestRoleUniqueGrantsProdAzureGroup() { + s.SetSelfSuperAdminForDB() + a := s.TestData.Role_TerraSuitableEngineer() + b := s.TestData.Role_TerraEngineer() + b.GrantsProdAzureGroup = a.GrantsProdAzureGroup + err := s.DB.Save(&b).Error + s.ErrorContains(err, "grants_prod_azure_group") + s.ErrorContains(err, "violates unique constraint") +} + +func TestRole_AssignmentsMap(t *testing.T) { + type fields struct { + Model gorm.Model + PropagatedAt sql.NullTime + Assignments []*RoleAssignment + RoleFields RoleFields + previousFields RoleFields + } + tests := []struct { + name string + fields fields + want map[uint]RoleAssignment + }{ + { + name: "empty", + fields: fields{}, + want: map[uint]RoleAssignment{}, + }, + { + name: "non-empty", + fields: fields{ + Assignments: []*RoleAssignment{ + { + UserID: 1, + }, + { + UserID: 2, + }, + }, + }, + want: map[uint]RoleAssignment{ + 1: { + UserID: 1, + }, + 2: { + UserID: 2, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Role{ + Model: tt.fields.Model, + PropagatedAt: tt.fields.PropagatedAt, + Assignments: tt.fields.Assignments, + RoleFields: tt.fields.RoleFields, + previousFields: tt.fields.previousFields, + } + assert.Equalf(t, tt.want, r.AssignmentsMap(), "AssignmentsMap()") + }) + } +} diff --git a/sherlock/internal/models/test_data.go b/sherlock/internal/models/test_data.go index 16b69a98d..b492a8a93 100644 --- a/sherlock/internal/models/test_data.go +++ b/sherlock/internal/models/test_data.go @@ -357,10 +357,13 @@ func (td *testDataImpl) Role_TerraSuitableEngineer() Role { if td.role_terraSuitableEngineer.ID == 0 { td.role_terraSuitableEngineer = Role{ RoleFields: RoleFields{ - Name: utils.PointerTo("terra-suitable-engineer"), - SuspendNonSuitableUsers: utils.PointerTo(true), - GrantsDevFirecloudGroup: utils.PointerTo("terra-suitable-engineer"), - GrantsDevAzureGroup: utils.PointerTo("terra-suitable-engineer"), + Name: utils.PointerTo("terra-suitable-engineer"), + SuspendNonSuitableUsers: utils.PointerTo(true), + GrantsDevFirecloudGroup: utils.PointerTo("terra-suitable-engineer-dev"), + GrantsQaFirecloudGroup: utils.PointerTo("terra-suitable-engineer-qa"), + GrantsProdFirecloudGroup: utils.PointerTo("terra-suitable-engineer-prod"), + GrantsDevAzureGroup: utils.PointerTo("00000000-0000-0000-0000-000000000001"), + GrantsProdAzureGroup: utils.PointerTo("00000000-0000-0000-0000-000000000002"), }, } td.h.SetSelfSuperAdminForDB() diff --git a/sherlock/internal/models/user.go b/sherlock/internal/models/user.go index d10c49d34..ab3b61cb2 100644 --- a/sherlock/internal/models/user.go +++ b/sherlock/internal/models/user.go @@ -186,10 +186,9 @@ func (u *User) ErrIfNotSuperAdmin() error { return nil } for _, assignment := range u.Assignments { - if assignment.Suspended != nil && - !*assignment.Suspended && - assignment.Role.GrantsSherlockSuperAdmin != nil && - *assignment.Role.GrantsSherlockSuperAdmin { + if assignment.Role.GrantsSherlockSuperAdmin != nil && + *assignment.Role.GrantsSherlockSuperAdmin && + assignment.IsActive() { return nil } } diff --git a/sherlock/internal/role_propagation/README.md b/sherlock/internal/role_propagation/README.md new file mode 100644 index 000000000..e8fe52d95 --- /dev/null +++ b/sherlock/internal/role_propagation/README.md @@ -0,0 +1,19 @@ +# `role_propagation` + +This package contains the logic for propagating different role grants out to their respective cloud providers. Put +differently, this package is the "group sync" part of Sherlock... plus the "manage Firecloud.org accounts" part, plus +the "manage GitHub Org members" part, and so on. This logic is generic so that with a grant stored on a role, and an +engine in `propagation_engines`, we can propagate that grant to the remote system or cloud provider. + +There's two sub-packages here: +- `intermediary_user` contains the type definitions for how we understand who does or doesn't have a grant on a + particular cloud provider +- `propagation_engines` contains the adapters for actually bridging the logic here to actions on cloud providers + +This package itself is split up into three parts: +- `propagator.go`, and the files prefixed with `propagator_`, contain the logic for propagating a single grant from a + single role to a single cloud provider +- `propagate.go` knows how to run all propagators sequentially (and it knows how to do so non-concurrently) +- `boot.go` is run only during normal full Sherlock boot, and it wires up the set of propagators that we want to have at + runtime + - In tests, we can use `test_helpers.go` to wire up whatever we need diff --git a/sherlock/internal/role_propagation/boot.go b/sherlock/internal/role_propagation/boot.go new file mode 100644 index 000000000..7f71a37ef --- /dev/null +++ b/sherlock/internal/role_propagation/boot.go @@ -0,0 +1,50 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines" +) + +var propagators []propagator + +// Init sets up the propagators to be used during normal operation. They will +// run in the given order, which can be important if one creates accounts that +// a later one will attempt to put into groups. +func Init(ctx context.Context) error { + propagators = []propagator{ + + &propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "devFirecloudGroup", + getGrant: func(role models.Role) *string { return role.GrantsDevFirecloudGroup }, + engine: &propagation_engines.GoogleWorkspaceGroupEngine{}, + }, + &propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "qaFirecloudGroup", + getGrant: func(role models.Role) *string { return role.GrantsQaFirecloudGroup }, + engine: &propagation_engines.GoogleWorkspaceGroupEngine{}, + }, + &propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "prodFirecloudGroup", + getGrant: func(role models.Role) *string { return role.GrantsProdFirecloudGroup }, + engine: &propagation_engines.GoogleWorkspaceGroupEngine{}, + }, + + &propagatorImpl[string, propagation_engines.AzureGroupIdentifier, propagation_engines.AzureGroupFields]{ + configKey: "devAzureGroup", + getGrant: func(role models.Role) *string { return role.GrantsDevAzureGroup }, + engine: &propagation_engines.AzureGroupEngine{}, + }, + &propagatorImpl[string, propagation_engines.AzureGroupIdentifier, propagation_engines.AzureGroupFields]{ + configKey: "prodAzureGroup", + getGrant: func(role models.Role) *string { return role.GrantsProdAzureGroup }, + engine: &propagation_engines.AzureGroupEngine{}, + }, + } + for _, p := range propagators { + if err := p.Init(ctx); err != nil { + return err + } + } + return nil +} diff --git a/sherlock/internal/role_propagation/intermediary_user/README.md b/sherlock/internal/role_propagation/intermediary_user/README.md new file mode 100644 index 000000000..033c20f14 --- /dev/null +++ b/sherlock/internal/role_propagation/intermediary_user/README.md @@ -0,0 +1,8 @@ +# `intermediary_user` + +This package contains the type definitions for how other parts of role propagation should understand "principals" +granted some permission in a remote system. + +These type definitions are trivial -- see `./identifier.go` -- but we have them in a separate package because we use +them in function signatures and type definitions in other packages. Having them in this separate package means that +we won't hit issues with circular dependencies in our mocks. diff --git a/sherlock/internal/role_propagation/intermediary_user/fields.go b/sherlock/internal/role_propagation/intermediary_user/fields.go new file mode 100644 index 000000000..fb05bf35f --- /dev/null +++ b/sherlock/internal/role_propagation/intermediary_user/fields.go @@ -0,0 +1,12 @@ +package intermediary_user + +// Fields represents the data that Sherlock should control about a user on the cloud provider (as opposed to the data +// that identifies the user on the cloud provider -- see Identifier). +// +// Implementations should generally be in propagation_engines (to be coupled to an engine) and should use non-pointer +// receivers. +type Fields interface { + // EqualTo returns true if the two Fields are equal. This is used to determine if a user's fields have changed and + // need to be updated. + EqualTo(other Fields) bool +} diff --git a/sherlock/internal/role_propagation/intermediary_user/identifier.go b/sherlock/internal/role_propagation/intermediary_user/identifier.go new file mode 100644 index 000000000..1b4e28f7a --- /dev/null +++ b/sherlock/internal/role_propagation/intermediary_user/identifier.go @@ -0,0 +1,12 @@ +package intermediary_user + +// Identifier represents the data that Sherlock should use to identify users on the cloud provider (as opposed to the +// data that Sherlock should control about the user on the cloud provider -- see Fields). +// +// Implementations should generally be in propagation_engines (to be coupled to an engine) and should use non-pointer +// receivers. +type Identifier interface { + // EqualTo returns true if the two Identifiers are equal. This is used to match users in the cloud provider to + // users in Sherlock. + EqualTo(other Identifier) bool +} diff --git a/sherlock/internal/role_propagation/intermediary_user/intermediary_user.go b/sherlock/internal/role_propagation/intermediary_user/intermediary_user.go new file mode 100644 index 000000000..b5b5e0c40 --- /dev/null +++ b/sherlock/internal/role_propagation/intermediary_user/intermediary_user.go @@ -0,0 +1,16 @@ +// Package intermediary_user helps represent the intersection of "what Sherlock cares about" and "what the cloud +// provider cares about" for the purposes of role propagation. The Identifier is a struct with whatever's needed to +// identify the user uniquely in the cloud provider, and the Fields is a struct with anything else Sherlock should +// control about the user in the cloud provider. +// +// Implementations of Identifier and Fields should be in propagation_engines, not here (since they should be tightly +// coupled to the propagation engine that uses them, and even if they aren't it just helps to be consistent). +package intermediary_user + +type IntermediaryUser[ + I Identifier, + F Fields, +] struct { + Identifier I + Fields F +} diff --git a/sherlock/internal/role_propagation/intermediary_user/intermediary_user_mocks/mock_fields.go b/sherlock/internal/role_propagation/intermediary_user/intermediary_user_mocks/mock_fields.go new file mode 100644 index 000000000..0b247f344 --- /dev/null +++ b/sherlock/internal/role_propagation/intermediary_user/intermediary_user_mocks/mock_fields.go @@ -0,0 +1,77 @@ +// Code generated by mockery v2.32.4. DO NOT EDIT. + +package intermediary_user_mocks + +import ( + intermediary_user "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + mock "github.com/stretchr/testify/mock" +) + +// MockFields is an autogenerated mock type for the Fields type +type MockFields struct { + mock.Mock +} + +type MockFields_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFields) EXPECT() *MockFields_Expecter { + return &MockFields_Expecter{mock: &_m.Mock} +} + +// EqualTo provides a mock function with given fields: other +func (_m *MockFields) EqualTo(other intermediary_user.Fields) bool { + ret := _m.Called(other) + + var r0 bool + if rf, ok := ret.Get(0).(func(intermediary_user.Fields) bool); ok { + r0 = rf(other) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockFields_EqualTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EqualTo' +type MockFields_EqualTo_Call struct { + *mock.Call +} + +// EqualTo is a helper method to define mock.On call +// - other intermediary_user.Fields +func (_e *MockFields_Expecter) EqualTo(other interface{}) *MockFields_EqualTo_Call { + return &MockFields_EqualTo_Call{Call: _e.mock.On("EqualTo", other)} +} + +func (_c *MockFields_EqualTo_Call) Run(run func(other intermediary_user.Fields)) *MockFields_EqualTo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(intermediary_user.Fields)) + }) + return _c +} + +func (_c *MockFields_EqualTo_Call) Return(_a0 bool) *MockFields_EqualTo_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockFields_EqualTo_Call) RunAndReturn(run func(intermediary_user.Fields) bool) *MockFields_EqualTo_Call { + _c.Call.Return(run) + return _c +} + +// NewMockFields creates a new instance of MockFields. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFields(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFields { + mock := &MockFields{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sherlock/internal/role_propagation/intermediary_user/intermediary_user_mocks/mock_identifier.go b/sherlock/internal/role_propagation/intermediary_user/intermediary_user_mocks/mock_identifier.go new file mode 100644 index 000000000..749a00436 --- /dev/null +++ b/sherlock/internal/role_propagation/intermediary_user/intermediary_user_mocks/mock_identifier.go @@ -0,0 +1,77 @@ +// Code generated by mockery v2.32.4. DO NOT EDIT. + +package intermediary_user_mocks + +import ( + intermediary_user "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + mock "github.com/stretchr/testify/mock" +) + +// MockIdentifier is an autogenerated mock type for the Identifier type +type MockIdentifier struct { + mock.Mock +} + +type MockIdentifier_Expecter struct { + mock *mock.Mock +} + +func (_m *MockIdentifier) EXPECT() *MockIdentifier_Expecter { + return &MockIdentifier_Expecter{mock: &_m.Mock} +} + +// EqualTo provides a mock function with given fields: other +func (_m *MockIdentifier) EqualTo(other intermediary_user.Identifier) bool { + ret := _m.Called(other) + + var r0 bool + if rf, ok := ret.Get(0).(func(intermediary_user.Identifier) bool); ok { + r0 = rf(other) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockIdentifier_EqualTo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EqualTo' +type MockIdentifier_EqualTo_Call struct { + *mock.Call +} + +// EqualTo is a helper method to define mock.On call +// - other intermediary_user.Identifier +func (_e *MockIdentifier_Expecter) EqualTo(other interface{}) *MockIdentifier_EqualTo_Call { + return &MockIdentifier_EqualTo_Call{Call: _e.mock.On("EqualTo", other)} +} + +func (_c *MockIdentifier_EqualTo_Call) Run(run func(other intermediary_user.Identifier)) *MockIdentifier_EqualTo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(intermediary_user.Identifier)) + }) + return _c +} + +func (_c *MockIdentifier_EqualTo_Call) Return(_a0 bool) *MockIdentifier_EqualTo_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockIdentifier_EqualTo_Call) RunAndReturn(run func(intermediary_user.Identifier) bool) *MockIdentifier_EqualTo_Call { + _c.Call.Return(run) + return _c +} + +// NewMockIdentifier creates a new instance of MockIdentifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockIdentifier(t interface { + mock.TestingT + Cleanup(func()) +}) *MockIdentifier { + mock := &MockIdentifier{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sherlock/internal/role_propagation/propagate.go b/sherlock/internal/role_propagation/propagate.go new file mode 100644 index 000000000..17bfc78b2 --- /dev/null +++ b/sherlock/internal/role_propagation/propagate.go @@ -0,0 +1,136 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/config" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/rs/zerolog/log" + "gorm.io/gorm" + "time" +) + +func DoOnDemandPropagation(ctx context.Context, db *gorm.DB, roleID uint) { + if config.Config.Bool("rolePropagation.asynchronous") { + go waitToPropagate(ctx, db, roleID) + } else { + waitToPropagate(ctx, db, roleID) + } + +} + +// waitToPropagate is a blocking function that will forcibly run propagation for +// the given role. It will wait until it can acquire a propagation lock on the +// role. +func waitToPropagate(ctx context.Context, db *gorm.DB, roleID uint) { + + // Load the role so we can lock it + var role models.Role + if err := db.Take(&role, roleID).Error; err != nil { + log.Error().Err(err).Msgf("failed to load role %d", roleID) + return + } + + err := db.Transaction(func(tx *gorm.DB) error { + // Acquire lock, which will be released when the transaction ends + if err := role.WaitPropagationLock(tx); err != nil { + return err + } + + // Reload the role with all associations now that we've locked it + if err := db.Scopes(models.ReadRoleScope).Take(&role, roleID).Error; err != nil { + return err + } + + // Do the propagation + doNonConcurrentPropagation(ctx, role) + + // Update the role's PropagatedAt field + return role.UpdatePropagatedAt(tx) + }) + if err != nil { + log.Error().Err(err).Msgf("failed to propagate role %s (%d)", *role.Name, roleID) + } +} + +// KeepPropagatingStale runs tryToPropagateStale every 30 seconds. +func KeepPropagatingStale(ctx context.Context, db *gorm.DB) { + for { + select { + case <-ctx.Done(): + return + default: + tryToPropagateStale(ctx, db) + } + time.Sleep(30 * time.Second) + } +} + +// tryToPropagateStale will attempt to Propagate all roles that are "stale" -- meaning they +// were last propagated past the threshold defined in the config. It will skip roles that +// have been propagated more recently or ones that it can't immediately obtain a lock on +// (as this implies some other process is already looking at it). +func tryToPropagateStale(ctx context.Context, db *gorm.DB) { + + // Get the list of roles to Propagate + var roleIDs []uint + if err := db.Model(&models.Role{}).Pluck("id", &roleIDs).Error; err != nil { + log.Error().Err(err).Msg("failed to get list of roles to propagate") + return + } + + for _, roleID := range roleIDs { + + // Load the role so we can lock it + var role models.Role + if err := db.Take(&role, roleID).Error; err != nil { + log.Error().Err(err).Msgf("failed to load role %d", roleID) + continue + } + + err := db.Transaction(func(tx *gorm.DB) error { + // Try to acquire lock, which will be released when the transaction ends + if obtainedLock, err := role.TryPropagationLock(tx); err != nil { + return err + } else if !obtainedLock { + // If it was already locked, another process is already looking at this role, so we skip + return nil + } + + // Reload the role with all associations now that we've locked it + if err := db.Scopes(models.ReadRoleScope).Take(&role, roleID).Error; err != nil { + return err + } + + // If the role was propagated recently, we skip + threshold := time.Now().Add(-1 * config.Config.Duration("rolePropagation.driftAlignmentStaleThreshold")) + if role.PropagatedAt.Valid && role.PropagatedAt.Time.After(threshold) { + return nil + } + + // Do the propagation + doNonConcurrentPropagation(ctx, role) + + // Update the role's PropagatedAt field + return role.UpdatePropagatedAt(tx) + }) + if err != nil { + log.Error().Err(err).Msgf("failed to propagate role %s (%d)", *role.Name, roleID) + } + } +} + +// doNonConcurrentPropagation runs each propagator in sequence for the given role. +// It assumes it won't run concurrently for the same role, even across replicas. +// This means that the propagators don't need to be idempotent (though the +// propagators could be used concurrently for different roles, so they can't be +// naively stateful). +func doNonConcurrentPropagation(ctx context.Context, role models.Role) { + for _, p := range propagators { + results, errors := p.Propagate(ctx, role) + if len(errors) > 0 { + log.Error().Errs("errors", errors).Strs("results", results).Msgf("%s propagation failed for role %s (%d)", p.Name(), *role.Name, role.ID) + } else { + log.Info().Strs("results", results).Msgf("%s propagation succeeded for role %s (%d)", p.Name(), *role.Name, role.ID) + } + } +} diff --git a/sherlock/internal/role_propagation/propagate_test.go b/sherlock/internal/role_propagation/propagate_test.go new file mode 100644 index 000000000..ffdbbd864 --- /dev/null +++ b/sherlock/internal/role_propagation/propagate_test.go @@ -0,0 +1,115 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/config" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/role_propagation_mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "testing" +) + +type propagateSuite struct { + suite.Suite + models.TestSuiteHelper +} + +func TestPropagateSuite(t *testing.T) { + suite.Run(t, new(propagateSuite)) +} + +func (s *propagateSuite) Test_waitToPropagate_notFound() { + config.LoadTestConfig() + roleID := uint(0) + + UseMockedPropagator(s.T(), func(c *role_propagation_mocks.MockPropagator) { + // No mock configuration -- calling anything will error + }, func() { + ctx := context.Background() + waitToPropagate(ctx, s.DB, roleID) // This gets run asynchronously, so there's no return value + }) +} + +func (s *propagateSuite) Test_waitToPropagate() { + roleID := s.TestData.Role_TerraEngineer().ID + + s.Run("propagatedAt null to start", func() { + var role models.Role + s.Assert().NoError(s.DB.Take(&role, roleID).Error) + if s.Assert().NotNil(role.PropagatedAt) { + s.Assert().False(role.PropagatedAt.Valid) + } + }) + + UseMockedPropagator(s.T(), func(c *role_propagation_mocks.MockPropagator) { + c.EXPECT().Propagate(mock.Anything, mock.MatchedBy(func(r models.Role) bool { + return r.ID == roleID + })).Return(nil, nil) + c.EXPECT().Name().Return("mockedPropagator") + }, func() { + ctx := context.Background() + waitToPropagate(ctx, s.DB, roleID) + }) + + s.Run("propagatedAt updated", func() { + var role models.Role + s.Assert().NoError(s.DB.Take(&role, roleID).Error) + if s.Assert().NotNil(role.PropagatedAt) { + s.Assert().True(role.PropagatedAt.Valid) + } + }) + + s.Run("locks released upon completion", func() { + // Just do the same thing again, checking that we're able to (if this never completes, it'd + // be some issue with the lock) + UseMockedPropagator(s.T(), func(c *role_propagation_mocks.MockPropagator) { + c.EXPECT().Propagate(mock.Anything, mock.MatchedBy(func(r models.Role) bool { + return r.ID == roleID + })).Return(nil, nil) + c.EXPECT().Name().Return("mockedPropagator") + }, func() { + ctx := context.Background() + waitToPropagate(ctx, s.DB, roleID) + }) + }) +} + +func (s *propagateSuite) Test_tryToPropagateStale() { + roleID := s.TestData.Role_TerraEngineer().ID + + s.Run("propagatedAt null to start", func() { + var role models.Role + s.Assert().NoError(s.DB.Take(&role, roleID).Error) + if s.Assert().NotNil(role.PropagatedAt) { + s.Assert().False(role.PropagatedAt.Valid) + } + }) + + UseMockedPropagator(s.T(), func(c *role_propagation_mocks.MockPropagator) { + c.EXPECT().Propagate(mock.Anything, mock.MatchedBy(func(r models.Role) bool { + return r.ID == roleID + })).Return(nil, nil) + c.EXPECT().Name().Return("mockedPropagator") + }, func() { + ctx := context.Background() + tryToPropagateStale(ctx, s.DB) + }) + + s.Run("propagatedAt updated", func() { + var role models.Role + s.Assert().NoError(s.DB.Take(&role, roleID).Error) + if s.Assert().NotNil(role.PropagatedAt) { + s.Assert().True(role.PropagatedAt.Valid) + } + }) + + s.Run("not stale anymore", func() { + UseMockedPropagator(s.T(), func(c *role_propagation_mocks.MockPropagator) { + // No mock configuration -- calling anything will error + }, func() { + ctx := context.Background() + tryToPropagateStale(ctx, s.DB) + }) + }) +} diff --git a/sherlock/internal/role_propagation/propagation_engines/README.md b/sherlock/internal/role_propagation/propagation_engines/README.md new file mode 100644 index 000000000..524ab5caf --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/README.md @@ -0,0 +1,18 @@ +# `propagation_engines` + +This package contains the adapters that bridge between Sherlock's actual propagation logic in `role_propagation` and the +mechanics of each cloud provider. With the `intermediary_user` package, the goal is that each of an engine's methods +should simply delegate to the appropriate client library: repeated conversions to and from cloud provider types should +be unnecessary. + +Put differently, an engine can "speak" UUIDs or whatever else is most convenient, so that we don't need to make as many +API calls. Fewer API calls means less code and less chance of hitting rate limits. + +Using that UUID example, an engine would be responsible for the following: +- Reading UUIDs that currently have the permission in the remote system +- Determining the UUIDs that should have the permission in the remote system, given a dump of Sherlock's RoleAssignments and Users +- Adding a UUID to the permission in the remote system +- Updating information fields for a UUID in the remote system (if relevant -- think first and last name or profile photo) +- Removing a UUID from the permission in the remote system + +The `role_propagation` package has all the logic for actually making propagation happen using these primitives. diff --git a/sherlock/internal/role_propagation/propagation_engines/azure_group.go b/sherlock/internal/role_propagation/propagation_engines/azure_group.go new file mode 100644 index 000000000..fa1bc373c --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/azure_group.go @@ -0,0 +1,143 @@ +package propagation_engines + +import ( + "context" + "fmt" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/broadinstitute/sherlock/go-shared/pkg/utils" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/knadh/koanf" + msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" + graphmodels "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/microsoftgraph/msgraph-sdk-go/users" + "strings" +) + +type AzureGroupIdentifier struct { + // ID is technically a UUID, but this is an intermediary type so we'd just be more brittle if we enforced that. + // Also makes parsing via koanf more complicated, mapstructure requires a tiny bit of custom code to handle UUIDs. + ID string `koanf:"id"` +} + +func (a AzureGroupIdentifier) EqualTo(other intermediary_user.Identifier) bool { + switch other := other.(type) { + case AzureGroupIdentifier: + return a.ID == other.ID + default: + return false + } +} + +type AzureGroupFields struct{} + +func (a AzureGroupFields) EqualTo(other intermediary_user.Fields) bool { + switch other.(type) { + case AzureGroupFields: + return true + default: + return false + } +} + +type AzureGroupEngine struct { + memberEmailSuffix string + userEmailSuffixesToReplace []string + client *msgraphsdk.GraphServiceClient +} + +func (a *AzureGroupEngine) Init(_ context.Context, k *koanf.Koanf) error { + a.memberEmailSuffix = k.String("memberEmailSuffix") + a.userEmailSuffixesToReplace = k.Strings("userEmailSuffixesToReplace") + + credentials, err := azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ + ClientID: k.String("clientID"), + TenantID: k.String("tenantID"), + TokenFilePath: k.String("tokenFilePath"), + }) + if err != nil { + return err + } + + a.client, err = msgraphsdk.NewGraphServiceClientWithCredentials(credentials, nil) + return err +} + +func (a *AzureGroupEngine) LoadCurrentState(ctx context.Context, grant string) ([]intermediary_user.IntermediaryUser[AzureGroupIdentifier, AzureGroupFields], error) { + currentState := make([]intermediary_user.IntermediaryUser[AzureGroupIdentifier, AzureGroupFields], 0) + groupMembersResponse, err := a.client.Groups().ByGroupId(grant).Members().Get(ctx, nil) + if err != nil { + return nil, err + } else { + for _, directoryObject := range groupMembersResponse.GetValue() { + if id := directoryObject.GetId(); id != nil { + currentState = append(currentState, intermediary_user.IntermediaryUser[AzureGroupIdentifier, AzureGroupFields]{ + Identifier: AzureGroupIdentifier{ID: *id}, + Fields: AzureGroupFields{}, + }) + } + } + } + return currentState, nil +} + +func (a *AzureGroupEngine) GenerateDesiredState(ctx context.Context, roleAssignments map[uint]models.RoleAssignment) (map[uint]intermediary_user.IntermediaryUser[AzureGroupIdentifier, AzureGroupFields], error) { + desiredState := make(map[uint]intermediary_user.IntermediaryUser[AzureGroupIdentifier, AzureGroupFields]) + for sherlockUserID, roleAssignment := range roleAssignments { + if !roleAssignment.IsActive() { + // There's no concept of a suspended group member, so we just exclude any non-active users + continue + } + + email := utils.SubstituteSuffix(roleAssignment.User.Email, a.userEmailSuffixesToReplace, a.memberEmailSuffix) + if !strings.HasSuffix(email, a.memberEmailSuffix) { + // We can short-circuit here, we know that the user is not in the expected member domain so we won't bother looking + continue + } + + usersResponse, err := a.client.Users().Get(ctx, &users.UsersRequestBuilderGetRequestConfiguration{ + QueryParameters: &users.UsersRequestBuilderGetQueryParameters{ + Select: []string{"id"}, + Filter: utils.PointerTo(fmt.Sprintf("userPrincipalName eq '%s'", email)), + Top: utils.PointerTo[int32](1), + }, + }) + if err != nil { + return nil, err + } else { + for _, user := range usersResponse.GetValue() { + if id := user.GetId(); id != nil { + desiredState[sherlockUserID] = intermediary_user.IntermediaryUser[AzureGroupIdentifier, AzureGroupFields]{ + Identifier: AzureGroupIdentifier{ID: *user.GetId()}, + Fields: AzureGroupFields{}, + } + } + } + } + } + return desiredState, nil +} + +func (a *AzureGroupEngine) Add(ctx context.Context, grant string, identifier AzureGroupIdentifier, _ AzureGroupFields) (string, error) { + body := graphmodels.NewReferenceCreate() + body.SetOdataId(utils.PointerTo(fmt.Sprintf("https://graph.microsoft.com/v1.0/directoryObjects/%s", identifier.ID))) + err := a.client.Groups().ByGroupId(grant).Members().Ref().Post(ctx, body, nil) + if err != nil { + return "", fmt.Errorf("failed to add user %s to group %s: %w", identifier.ID, grant, err) + } else { + return fmt.Sprintf("added user %s to group %s", identifier.ID, grant), nil + } +} + +func (a *AzureGroupEngine) Update(_ context.Context, _ string, _ AzureGroupIdentifier, _ AzureGroupFields, _ AzureGroupFields) (string, error) { + return "", fmt.Errorf("%T.Update not implemented, %T.EqualTo should always return true", a, AzureGroupFields{}) +} + +func (a *AzureGroupEngine) Remove(ctx context.Context, grant string, identifier AzureGroupIdentifier) (string, error) { + err := a.client.Groups().ByGroupId(grant).Members().ByDirectoryObjectId(identifier.ID).Ref().Delete(ctx, nil) + if err != nil { + return "", fmt.Errorf("failed to remove user %s from group %s: %w", identifier.ID, grant, err) + } else { + return fmt.Sprintf("removed user %s from group %s", identifier.ID, grant), nil + } +} diff --git a/sherlock/internal/role_propagation/propagation_engines/azure_group_test.go b/sherlock/internal/role_propagation/propagation_engines/azure_group_test.go new file mode 100644 index 000000000..f438c0ea4 --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/azure_group_test.go @@ -0,0 +1,153 @@ +package propagation_engines + +import ( + "context" + "github.com/broadinstitute/sherlock/go-shared/pkg/utils" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestAzureGroupIdentifier_EqualTo(t *testing.T) { + type fields struct { + ID string + } + type args struct { + other intermediary_user.Identifier + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + name: "equal", + fields: fields{ + ID: "foo", + }, + args: args{ + other: AzureGroupIdentifier{ + ID: "foo", + }, + }, + want: true, + }, + { + name: "not equal", + fields: fields{ + ID: "foo", + }, + args: args{ + other: AzureGroupIdentifier{ + ID: "bar", + }, + }, + want: false, + }, + { + name: "different type", + fields: fields{ + ID: "foo", + }, + args: args{ + other: GoogleWorkspaceGroupIdentifier{ + Email: "foo", + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := AzureGroupIdentifier{ + ID: tt.fields.ID, + } + assert.Equalf(t, tt.want, a.EqualTo(tt.args.other), "EqualTo(%v)", tt.args.other) + }) + } +} + +func TestAzureGroupFields_EqualTo(t *testing.T) { + type args struct { + other intermediary_user.Fields + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "same type", + args: args{ + other: AzureGroupFields{}, + }, + want: true, + }, + { + name: "different type", + args: args{ + other: GoogleWorkspaceGroupFields{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := AzureGroupFields{} + assert.Equalf(t, tt.want, a.EqualTo(tt.args.other), "EqualTo(%v)", tt.args.other) + }) + } +} + +// We can't easily test the actual cloud logic, but we can test that we short circuit correctly for +// non-active role assignments. +func TestAzureGroupEngine_GenerateDesiredState_isActiveShortCircuit(t *testing.T) { + engine := &AzureGroupEngine{} + desiredState, err := engine.GenerateDesiredState(context.Background(), map[uint]models.RoleAssignment{ + 1: { + RoleAssignmentFields: models.RoleAssignmentFields{ + Suspended: utils.PointerTo(true), + }, + }, + 2: { + RoleAssignmentFields: models.RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + ExpiresAt: utils.PointerTo(time.Now().Add(-time.Hour)), + }, + }, + }) + assert.NoError(t, err) + assert.Empty(t, desiredState) +} + +// We can't easily test the actual cloud logic, but we can test that we short circuit correctly for +// emails that aren't in the target domain. +// +// See also utils.SubstituteSuffix +func TestAzureGroupEngine_GenerateDesiredState_emailShortCircuit(t *testing.T) { + engine := &AzureGroupEngine{ + memberEmailSuffix: "@example.com", + userEmailSuffixesToReplace: []string{"@example.org"}, + } + desiredState, err := engine.GenerateDesiredState(context.Background(), map[uint]models.RoleAssignment{ + 1: { + User: &models.User{ + Email: "user@example.net", + }, + RoleAssignmentFields: models.RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + }, + }, + }) + assert.NoError(t, err) + assert.Empty(t, desiredState) +} + +func TestAzureGroupEngine_Update_errors(t *testing.T) { + engine := &AzureGroupEngine{} + _, err := engine.Update(context.Background(), "", AzureGroupIdentifier{}, AzureGroupFields{}, AzureGroupFields{}) + assert.Error(t, err) +} diff --git a/sherlock/internal/role_propagation/propagation_engines/google_workspace_group.go b/sherlock/internal/role_propagation/propagation_engines/google_workspace_group.go new file mode 100644 index 000000000..3359f0f4e --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/google_workspace_group.go @@ -0,0 +1,127 @@ +package propagation_engines + +import ( + "context" + "fmt" + "github.com/broadinstitute/sherlock/go-shared/pkg/utils" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/knadh/koanf" + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + "strings" +) + +type GoogleWorkspaceGroupIdentifier struct { + Email string `koanf:"email"` +} + +func (f GoogleWorkspaceGroupIdentifier) EqualTo(other intermediary_user.Identifier) bool { + switch other := other.(type) { + case GoogleWorkspaceGroupIdentifier: + return f.Email == other.Email + default: + return false + } +} + +type GoogleWorkspaceGroupFields struct{} + +func (f GoogleWorkspaceGroupFields) EqualTo(other intermediary_user.Fields) bool { + switch other.(type) { + case GoogleWorkspaceGroupFields: + return true + default: + return false + } +} + +type GoogleWorkspaceGroupEngine struct { + workspaceDomain string + userEmailSuffixesToReplace []string + adminService *admin.Service +} + +func (f *GoogleWorkspaceGroupEngine) Init(ctx context.Context, k *koanf.Koanf) error { + f.workspaceDomain = k.String("workspaceDomain") + f.userEmailSuffixesToReplace = k.Strings("userEmailSuffixesToReplace") + var err error + f.adminService, err = admin.NewService(ctx, option.WithScopes(admin.AdminDirectoryUserScope, admin.AdminDirectoryGroupMemberScope)) + return err +} + +func (f *GoogleWorkspaceGroupEngine) LoadCurrentState(ctx context.Context, grant string) ([]intermediary_user.IntermediaryUser[GoogleWorkspaceGroupIdentifier, GoogleWorkspaceGroupFields], error) { + currentState := make([]intermediary_user.IntermediaryUser[GoogleWorkspaceGroupIdentifier, GoogleWorkspaceGroupFields], 0) + err := f.adminService.Members.List(grant).Pages(ctx, func(members *admin.Members) error { + for _, member := range members.Members { + currentState = append(currentState, intermediary_user.IntermediaryUser[GoogleWorkspaceGroupIdentifier, GoogleWorkspaceGroupFields]{ + Identifier: GoogleWorkspaceGroupIdentifier{Email: member.Email}, + Fields: GoogleWorkspaceGroupFields{}, + }) + } + return nil + }) + return currentState, err +} + +func (f *GoogleWorkspaceGroupEngine) GenerateDesiredState(ctx context.Context, roleAssignments map[uint]models.RoleAssignment) (map[uint]intermediary_user.IntermediaryUser[GoogleWorkspaceGroupIdentifier, GoogleWorkspaceGroupFields], error) { + desiredState := make(map[uint]intermediary_user.IntermediaryUser[GoogleWorkspaceGroupIdentifier, GoogleWorkspaceGroupFields]) + for id, roleAssignment := range roleAssignments { + if !roleAssignment.IsActive() { + // There's no concept of a suspended group member, so we just exclude any non-active users + continue + } + + email := utils.SubstituteSuffix(roleAssignment.User.Email, f.userEmailSuffixesToReplace, "@"+f.workspaceDomain) + if !strings.HasSuffix(email, "@"+f.workspaceDomain) { + // We can short-circuit here, we know that the user is not in the workspace domain so we won't bother looking + continue + } + + err := f.adminService.Users.List(). + Domain(f.workspaceDomain). + Query("email="+email). + Fields("users(primaryEmail)"). + MaxResults(1). + Pages(ctx, func(workspaceUsers *admin.Users) error { + for _, workspaceUser := range workspaceUsers.Users { + if workspaceUser.PrimaryEmail == email { + desiredState[id] = intermediary_user.IntermediaryUser[GoogleWorkspaceGroupIdentifier, GoogleWorkspaceGroupFields]{ + Identifier: GoogleWorkspaceGroupIdentifier{Email: email}, + Fields: GoogleWorkspaceGroupFields{}, + } + } + } + return nil + }) + if err != nil { + return nil, err + } + } + return desiredState, nil +} + +func (f *GoogleWorkspaceGroupEngine) Add(ctx context.Context, grant string, identifier GoogleWorkspaceGroupIdentifier, _ GoogleWorkspaceGroupFields) (string, error) { + response, err := f.adminService.Members.Insert(grant, &admin.Member{ + Role: "MEMBER", + Email: identifier.Email, + }).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("failed to add %s to %s: %w", identifier.Email, grant, err) + } else { + return fmt.Sprintf("added %s to %s", response.Email, grant), nil + } +} + +func (f *GoogleWorkspaceGroupEngine) Update(_ context.Context, _ string, _ GoogleWorkspaceGroupIdentifier, _ GoogleWorkspaceGroupFields, _ GoogleWorkspaceGroupFields) (string, error) { + return "", fmt.Errorf("%T.Update not implemented; %T.EqualTo should always equal true", f, GoogleWorkspaceGroupFields{}) +} + +func (f *GoogleWorkspaceGroupEngine) Remove(ctx context.Context, grant string, identifier GoogleWorkspaceGroupIdentifier) (string, error) { + err := f.adminService.Members.Delete(grant, identifier.Email).Context(ctx).Do() + if err != nil { + return "", fmt.Errorf("failed to remove %s from %s: %w", identifier.Email, grant, err) + } else { + return fmt.Sprintf("removed %s from %s", identifier.Email, grant), nil + } +} diff --git a/sherlock/internal/role_propagation/propagation_engines/google_workspace_group_test.go b/sherlock/internal/role_propagation/propagation_engines/google_workspace_group_test.go new file mode 100644 index 000000000..43684406a --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/google_workspace_group_test.go @@ -0,0 +1,153 @@ +package propagation_engines + +import ( + "context" + "github.com/broadinstitute/sherlock/go-shared/pkg/utils" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestGoogleWorkspaceGroupIdentifier_EqualTo(t *testing.T) { + type fields struct { + Email string + } + type args struct { + other intermediary_user.Identifier + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + name: "equal", + fields: fields{ + Email: "foo", + }, + args: args{ + other: GoogleWorkspaceGroupIdentifier{ + Email: "foo", + }, + }, + want: true, + }, + { + name: "not equal", + fields: fields{ + Email: "foo", + }, + args: args{ + other: GoogleWorkspaceGroupIdentifier{ + Email: "bar", + }, + }, + want: false, + }, + { + name: "different type", + fields: fields{ + Email: "foo", + }, + args: args{ + other: AzureGroupIdentifier{ + ID: "foo", + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := GoogleWorkspaceGroupIdentifier{ + Email: tt.fields.Email, + } + assert.Equalf(t, tt.want, a.EqualTo(tt.args.other), "EqualTo(%v)", tt.args.other) + }) + } +} + +func TestGoogleWorkspaceGroupFields_EqualTo(t *testing.T) { + type args struct { + other intermediary_user.Fields + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "same type", + args: args{ + other: GoogleWorkspaceGroupFields{}, + }, + want: true, + }, + { + name: "different type", + args: args{ + other: AzureGroupFields{}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := GoogleWorkspaceGroupFields{} + assert.Equalf(t, tt.want, f.EqualTo(tt.args.other), "EqualTo(%v)", tt.args.other) + }) + } +} + +// We can't easily test the actual cloud logic, but we can test that we short circuit correctly for +// non-active role assignments. +func TestGoogleWorkspaceGroupEngine_GenerateDesiredState_isActiveShortCircuit(t *testing.T) { + engine := &GoogleWorkspaceGroupEngine{} + desiredState, err := engine.GenerateDesiredState(context.Background(), map[uint]models.RoleAssignment{ + 1: { + RoleAssignmentFields: models.RoleAssignmentFields{ + Suspended: utils.PointerTo(true), + }, + }, + 2: { + RoleAssignmentFields: models.RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + ExpiresAt: utils.PointerTo(time.Now().Add(-time.Hour)), + }, + }, + }) + assert.NoError(t, err) + assert.Empty(t, desiredState) +} + +// We can't easily test the actual cloud logic, but we can test that we short circuit correctly for +// emails that aren't in the target domain. +// +// See also utils.SubstituteSuffix +func TestGoogleWorkspaceGroupEngine_GenerateDesiredState_emailShortCircuit(t *testing.T) { + engine := &GoogleWorkspaceGroupEngine{ + workspaceDomain: "example.com", + userEmailSuffixesToReplace: []string{"@example.org"}, + } + desiredState, err := engine.GenerateDesiredState(context.Background(), map[uint]models.RoleAssignment{ + 1: { + User: &models.User{ + Email: "user@example.net", + }, + RoleAssignmentFields: models.RoleAssignmentFields{ + Suspended: utils.PointerTo(false), + }, + }, + }) + assert.NoError(t, err) + assert.Empty(t, desiredState) +} + +func TestGoogleWorkspaceGroupEngine_Update_errors(t *testing.T) { + engine := &GoogleWorkspaceGroupEngine{} + _, err := engine.Update(context.Background(), "", GoogleWorkspaceGroupIdentifier{}, GoogleWorkspaceGroupFields{}, GoogleWorkspaceGroupFields{}) + assert.Error(t, err) +} diff --git a/sherlock/internal/role_propagation/propagation_engines/propagation_engine.go b/sherlock/internal/role_propagation/propagation_engines/propagation_engine.go new file mode 100644 index 000000000..7b4a6e587 --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/propagation_engine.go @@ -0,0 +1,74 @@ +package propagation_engines + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/knadh/koanf" +) + +// PropagationEngine represents a mechanism to propagate role assignments out into the cloud. You can think of a +// propagationEngine implementation as handling one "type" of grant field on a models.Role, like "Firecloud.org group" +// or "Firecloud.org account." There might be multiple of those fields on models.Role, like for dev/QA/prod +// Firecloud.org, and the idea is there'd be an engine instance per each one of those (just with different config). +// +// See propagators in ./boot.go for examples. Implementations of this interface should generally use pointer receivers. +type PropagationEngine[ + // Grant is what we're trying to, well, grant. In many cases, this'll be a string, representing the name of the + // group we want to add people to. It could be a UUID for granting some Azure role or permission. The weirdest + // likely case is that it could be a boolean, if the propagator were meant to grant an actual account or something. + // + // A good rule of thumb for understanding this type is that it's however the grant is stored on the models.Role. + Grant any, + // Identifier is a struct containing how the engine identifies users on the cloud provider. The key thing is that + // the engine should be able to read this from the cloud provider, so that means "Sherlock user ID" should almost + // definitely not be in here. For granting a Google group, this might just be the email address of the user. + // + // A good rule of thumb for understanding this type is that it provides just enough information for us to "get" + // a user on the cloud provider. + Identifier intermediary_user.Identifier, + // Fields is a struct containing non-identifying but still Sherlock-manipulated data for the user on the cloud + // provider. For example, if we're granting a Firecloud.org account, the fields might contain the user's name, + // since we don't consider that unique but we do want to control it. Fields can also be how an engine represents + // suspensions. + // + // A good rule of thumb for understanding this type is that it contains all the information that we want to be + // able to change for a user on the cloud provider. + Fields intermediary_user.Fields, +] interface { + // Init runs any instance-specific setup for this engine. Errors returned here will abort Sherlock's boot process. + // The *koanf.Koanf is the instance-specific configuration. + Init(ctx context.Context, k *koanf.Koanf) error + + // LoadCurrentState loads and returns the current state of the grant on the cloud provider, like who all is in the + // group. This function shouldn't make any judgement about whether the remote state is correct or not -- it just + // tells us what it is right now. + LoadCurrentState(ctx context.Context, grant Grant) ([]intermediary_user.IntermediaryUser[Identifier, Fields], error) + + // GenerateDesiredState assembles the set of intermediary users that should have the grant. This function may + // return fewer results than the input map, for example if an input entry has no corresponding intermediary user + // or if an input entry is suspended and the engine handles that by not giving the intermediary user the grant + // at all. + GenerateDesiredState(ctx context.Context, roleAssignments map[uint]models.RoleAssignment) (map[uint]intermediary_user.IntermediaryUser[Identifier, Fields], error) + + // Add directs the engine to give the grant to the intermediary user (and set the given initial fields). + // It won't be called if the engine has reported the identifier as already having the grant. + // + // It should return a string that will be logged as the result of the operation. + Add(ctx context.Context, grant Grant, identifier Identifier, fields Fields) (string, error) + + // Update directs the engine to update the fields of the intermediary user on the given grant. + // It will only be called if getGrantState and translateRoleAssignments return intermediary users + // with equal identifiers but different fields (if the identifier isn't present in both or the fields + // are the same, this function won't be called). It's safe to leave this function unimplemented if the + // fields will always be the same (perhaps because it is an empty struct). + // + // It should return a string that will be logged as the result of the operation. + Update(ctx context.Context, grant Grant, identifier Identifier, oldFields Fields, newFields Fields) (string, error) + + // Remove directs the engine to remove the grant from the intermediary user. + // It won't be called if the engine hasn't reported the identifier as having the grant. + // + // It should return a string that will be logged as the result of the operation. + Remove(ctx context.Context, grant Grant, identifier Identifier) (string, error) +} diff --git a/sherlock/internal/role_propagation/propagation_engines/propagation_engines_mocks/mock_propagation_engine.go b/sherlock/internal/role_propagation/propagation_engines/propagation_engines_mocks/mock_propagation_engine.go new file mode 100644 index 000000000..1adeef75b --- /dev/null +++ b/sherlock/internal/role_propagation/propagation_engines/propagation_engines_mocks/mock_propagation_engine.go @@ -0,0 +1,359 @@ +// Code generated by mockery v2.32.4. DO NOT EDIT. + +package propagation_engines_mocks + +import ( + context "context" + + intermediary_user "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + koanf "github.com/knadh/koanf" + + mock "github.com/stretchr/testify/mock" + + models "github.com/broadinstitute/sherlock/sherlock/internal/models" +) + +// MockPropagationEngine is an autogenerated mock type for the PropagationEngine type +type MockPropagationEngine[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + mock.Mock +} + +type MockPropagationEngine_Expecter[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + mock *mock.Mock +} + +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) EXPECT() *MockPropagationEngine_Expecter[Grant, Identifier, Fields] { + return &MockPropagationEngine_Expecter[Grant, Identifier, Fields]{mock: &_m.Mock} +} + +// Add provides a mock function with given fields: ctx, grant, identifier, fields +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) Add(ctx context.Context, grant Grant, identifier Identifier, fields Fields) (string, error) { + ret := _m.Called(ctx, grant, identifier, fields) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, Grant, Identifier, Fields) (string, error)); ok { + return rf(ctx, grant, identifier, fields) + } + if rf, ok := ret.Get(0).(func(context.Context, Grant, Identifier, Fields) string); ok { + r0 = rf(ctx, grant, identifier, fields) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, Grant, Identifier, Fields) error); ok { + r1 = rf(ctx, grant, identifier, fields) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPropagationEngine_Add_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Add' +type MockPropagationEngine_Add_Call[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + *mock.Call +} + +// Add is a helper method to define mock.On call +// - ctx context.Context +// - grant Grant +// - identifier Identifier +// - fields Fields +func (_e *MockPropagationEngine_Expecter[Grant, Identifier, Fields]) Add(ctx interface{}, grant interface{}, identifier interface{}, fields interface{}) *MockPropagationEngine_Add_Call[Grant, Identifier, Fields] { + return &MockPropagationEngine_Add_Call[Grant, Identifier, Fields]{Call: _e.mock.On("Add", ctx, grant, identifier, fields)} +} + +func (_c *MockPropagationEngine_Add_Call[Grant, Identifier, Fields]) Run(run func(ctx context.Context, grant Grant, identifier Identifier, fields Fields)) *MockPropagationEngine_Add_Call[Grant, Identifier, Fields] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(Grant), args[2].(Identifier), args[3].(Fields)) + }) + return _c +} + +func (_c *MockPropagationEngine_Add_Call[Grant, Identifier, Fields]) Return(_a0 string, _a1 error) *MockPropagationEngine_Add_Call[Grant, Identifier, Fields] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPropagationEngine_Add_Call[Grant, Identifier, Fields]) RunAndReturn(run func(context.Context, Grant, Identifier, Fields) (string, error)) *MockPropagationEngine_Add_Call[Grant, Identifier, Fields] { + _c.Call.Return(run) + return _c +} + +// GenerateDesiredState provides a mock function with given fields: ctx, roleAssignments +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) GenerateDesiredState(ctx context.Context, roleAssignments map[uint]models.RoleAssignment) (map[uint]intermediary_user.IntermediaryUser[Identifier, Fields], error) { + ret := _m.Called(ctx, roleAssignments) + + var r0 map[uint]intermediary_user.IntermediaryUser[Identifier, Fields] + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, map[uint]models.RoleAssignment) (map[uint]intermediary_user.IntermediaryUser[Identifier, Fields], error)); ok { + return rf(ctx, roleAssignments) + } + if rf, ok := ret.Get(0).(func(context.Context, map[uint]models.RoleAssignment) map[uint]intermediary_user.IntermediaryUser[Identifier, Fields]); ok { + r0 = rf(ctx, roleAssignments) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[uint]intermediary_user.IntermediaryUser[Identifier, Fields]) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, map[uint]models.RoleAssignment) error); ok { + r1 = rf(ctx, roleAssignments) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPropagationEngine_GenerateDesiredState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateDesiredState' +type MockPropagationEngine_GenerateDesiredState_Call[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + *mock.Call +} + +// GenerateDesiredState is a helper method to define mock.On call +// - ctx context.Context +// - roleAssignments map[uint]models.RoleAssignment +func (_e *MockPropagationEngine_Expecter[Grant, Identifier, Fields]) GenerateDesiredState(ctx interface{}, roleAssignments interface{}) *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields] { + return &MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields]{Call: _e.mock.On("GenerateDesiredState", ctx, roleAssignments)} +} + +func (_c *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields]) Run(run func(ctx context.Context, roleAssignments map[uint]models.RoleAssignment)) *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(map[uint]models.RoleAssignment)) + }) + return _c +} + +func (_c *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields]) Return(_a0 map[uint]intermediary_user.IntermediaryUser[Identifier, Fields], _a1 error) *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields]) RunAndReturn(run func(context.Context, map[uint]models.RoleAssignment) (map[uint]intermediary_user.IntermediaryUser[Identifier, Fields], error)) *MockPropagationEngine_GenerateDesiredState_Call[Grant, Identifier, Fields] { + _c.Call.Return(run) + return _c +} + +// Init provides a mock function with given fields: ctx, k +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) Init(ctx context.Context, k *koanf.Koanf) error { + ret := _m.Called(ctx, k) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *koanf.Koanf) error); ok { + r0 = rf(ctx, k) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPropagationEngine_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init' +type MockPropagationEngine_Init_Call[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + *mock.Call +} + +// Init is a helper method to define mock.On call +// - ctx context.Context +// - k *koanf.Koanf +func (_e *MockPropagationEngine_Expecter[Grant, Identifier, Fields]) Init(ctx interface{}, k interface{}) *MockPropagationEngine_Init_Call[Grant, Identifier, Fields] { + return &MockPropagationEngine_Init_Call[Grant, Identifier, Fields]{Call: _e.mock.On("Init", ctx, k)} +} + +func (_c *MockPropagationEngine_Init_Call[Grant, Identifier, Fields]) Run(run func(ctx context.Context, k *koanf.Koanf)) *MockPropagationEngine_Init_Call[Grant, Identifier, Fields] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*koanf.Koanf)) + }) + return _c +} + +func (_c *MockPropagationEngine_Init_Call[Grant, Identifier, Fields]) Return(_a0 error) *MockPropagationEngine_Init_Call[Grant, Identifier, Fields] { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPropagationEngine_Init_Call[Grant, Identifier, Fields]) RunAndReturn(run func(context.Context, *koanf.Koanf) error) *MockPropagationEngine_Init_Call[Grant, Identifier, Fields] { + _c.Call.Return(run) + return _c +} + +// LoadCurrentState provides a mock function with given fields: ctx, grant +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) LoadCurrentState(ctx context.Context, grant Grant) ([]intermediary_user.IntermediaryUser[Identifier, Fields], error) { + ret := _m.Called(ctx, grant) + + var r0 []intermediary_user.IntermediaryUser[Identifier, Fields] + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, Grant) ([]intermediary_user.IntermediaryUser[Identifier, Fields], error)); ok { + return rf(ctx, grant) + } + if rf, ok := ret.Get(0).(func(context.Context, Grant) []intermediary_user.IntermediaryUser[Identifier, Fields]); ok { + r0 = rf(ctx, grant) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]intermediary_user.IntermediaryUser[Identifier, Fields]) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, Grant) error); ok { + r1 = rf(ctx, grant) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPropagationEngine_LoadCurrentState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadCurrentState' +type MockPropagationEngine_LoadCurrentState_Call[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + *mock.Call +} + +// LoadCurrentState is a helper method to define mock.On call +// - ctx context.Context +// - grant Grant +func (_e *MockPropagationEngine_Expecter[Grant, Identifier, Fields]) LoadCurrentState(ctx interface{}, grant interface{}) *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields] { + return &MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields]{Call: _e.mock.On("LoadCurrentState", ctx, grant)} +} + +func (_c *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields]) Run(run func(ctx context.Context, grant Grant)) *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(Grant)) + }) + return _c +} + +func (_c *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields]) Return(_a0 []intermediary_user.IntermediaryUser[Identifier, Fields], _a1 error) *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields]) RunAndReturn(run func(context.Context, Grant) ([]intermediary_user.IntermediaryUser[Identifier, Fields], error)) *MockPropagationEngine_LoadCurrentState_Call[Grant, Identifier, Fields] { + _c.Call.Return(run) + return _c +} + +// Remove provides a mock function with given fields: ctx, grant, identifier +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) Remove(ctx context.Context, grant Grant, identifier Identifier) (string, error) { + ret := _m.Called(ctx, grant, identifier) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, Grant, Identifier) (string, error)); ok { + return rf(ctx, grant, identifier) + } + if rf, ok := ret.Get(0).(func(context.Context, Grant, Identifier) string); ok { + r0 = rf(ctx, grant, identifier) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, Grant, Identifier) error); ok { + r1 = rf(ctx, grant, identifier) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPropagationEngine_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' +type MockPropagationEngine_Remove_Call[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + *mock.Call +} + +// Remove is a helper method to define mock.On call +// - ctx context.Context +// - grant Grant +// - identifier Identifier +func (_e *MockPropagationEngine_Expecter[Grant, Identifier, Fields]) Remove(ctx interface{}, grant interface{}, identifier interface{}) *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields] { + return &MockPropagationEngine_Remove_Call[Grant, Identifier, Fields]{Call: _e.mock.On("Remove", ctx, grant, identifier)} +} + +func (_c *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields]) Run(run func(ctx context.Context, grant Grant, identifier Identifier)) *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(Grant), args[2].(Identifier)) + }) + return _c +} + +func (_c *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields]) Return(_a0 string, _a1 error) *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields]) RunAndReturn(run func(context.Context, Grant, Identifier) (string, error)) *MockPropagationEngine_Remove_Call[Grant, Identifier, Fields] { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: ctx, grant, identifier, oldFields, newFields +func (_m *MockPropagationEngine[Grant, Identifier, Fields]) Update(ctx context.Context, grant Grant, identifier Identifier, oldFields Fields, newFields Fields) (string, error) { + ret := _m.Called(ctx, grant, identifier, oldFields, newFields) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, Grant, Identifier, Fields, Fields) (string, error)); ok { + return rf(ctx, grant, identifier, oldFields, newFields) + } + if rf, ok := ret.Get(0).(func(context.Context, Grant, Identifier, Fields, Fields) string); ok { + r0 = rf(ctx, grant, identifier, oldFields, newFields) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, Grant, Identifier, Fields, Fields) error); ok { + r1 = rf(ctx, grant, identifier, oldFields, newFields) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPropagationEngine_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockPropagationEngine_Update_Call[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - grant Grant +// - identifier Identifier +// - oldFields Fields +// - newFields Fields +func (_e *MockPropagationEngine_Expecter[Grant, Identifier, Fields]) Update(ctx interface{}, grant interface{}, identifier interface{}, oldFields interface{}, newFields interface{}) *MockPropagationEngine_Update_Call[Grant, Identifier, Fields] { + return &MockPropagationEngine_Update_Call[Grant, Identifier, Fields]{Call: _e.mock.On("Update", ctx, grant, identifier, oldFields, newFields)} +} + +func (_c *MockPropagationEngine_Update_Call[Grant, Identifier, Fields]) Run(run func(ctx context.Context, grant Grant, identifier Identifier, oldFields Fields, newFields Fields)) *MockPropagationEngine_Update_Call[Grant, Identifier, Fields] { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(Grant), args[2].(Identifier), args[3].(Fields), args[4].(Fields)) + }) + return _c +} + +func (_c *MockPropagationEngine_Update_Call[Grant, Identifier, Fields]) Return(_a0 string, _a1 error) *MockPropagationEngine_Update_Call[Grant, Identifier, Fields] { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPropagationEngine_Update_Call[Grant, Identifier, Fields]) RunAndReturn(run func(context.Context, Grant, Identifier, Fields, Fields) (string, error)) *MockPropagationEngine_Update_Call[Grant, Identifier, Fields] { + _c.Call.Return(run) + return _c +} + +// NewMockPropagationEngine creates a new instance of MockPropagationEngine. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPropagationEngine[Grant interface{}, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields](t interface { + mock.TestingT + Cleanup(func()) +}) *MockPropagationEngine[Grant, Identifier, Fields] { + mock := &MockPropagationEngine[Grant, Identifier, Fields]{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sherlock/internal/role_propagation/propagator.go b/sherlock/internal/role_propagation/propagator.go new file mode 100644 index 000000000..6d387c3ff --- /dev/null +++ b/sherlock/internal/role_propagation/propagator.go @@ -0,0 +1,85 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines" + "github.com/knadh/koanf" + "time" +) + +// propagator is an interface sitting just on top of propagatorImpl. The only reason it exists is that we can't +// use a `var propagators []propagatorImpl[any, any]` because Go doesn't support covariance like that -- we call +// it a `var propagators []propagator` instead. +type propagator interface { + // Name is how the propagator should be referenced in logs and alerts. + Name() string + // Init loads configuration and initializes the engine (assuming the configuration doesn't say this + // propagator is disabled). It should be called once at startup, and an error here should abort startup. + Init(ctx context.Context) error + // Propagate does the actual work of propagating the configured grant on the role. It is assumed to run + // non-concurrently for a given role. It short-circuits if this propagator is disabled. + Propagate(ctx context.Context, role models.Role) (results []string, errors []error) +} + +type propagatorImpl[ + // Grant is what we're trying to, well, grant. In many cases, this'll be a string, representing the name of the + // group we want to add people to. It could be a UUID for granting some Azure role or permission. The weirdest + // likely case is that it could be a boolean, if the propagator were meant to grant an actual account or something. + // + // A good rule of thumb for understanding this type is that it's however the grant is stored on the models.Role. + Grant any, + // Identifier is a struct containing how the engine identifies users on the cloud provider. The key thing is that + // the engine should be able to read this from the cloud provider, so that means "Sherlock user ID" should almost + // definitely not be in here. For granting a Google group, this might just be the email address of the user. + // + // A good rule of thumb for understanding this type is that it provides just enough information for us to "get" + // a user on the cloud provider. + Identifier intermediary_user.Identifier, + // Fields is a struct containing non-identifying but still Sherlock-manipulated data for the user on the cloud + // provider. For example, if we're granting a Firecloud.org account, the fields might contain the user's name, + // since we don't consider that unique but we do want to control it. Fields can also be how an engine represents + // suspensions. + // + // A good rule of thumb for understanding this type is that it contains all the information that we want to be + // able to change for a user on the cloud provider. + Fields intermediary_user.Fields, +] struct { + // configKey is used in rolePropagation.propagators. to load configuration for this propagatorImpl. + configKey string + + // getGrant reads the models.Role to tell us what we're trying to grant. See the Grant generic type for more info. + // A nil return value means the models.Role doesn't have anything for us to grant. + getGrant func(role models.Role) *Grant + + // engine is the implementation of the cloud-specific logic for introspecting and adjusting the grant's state. + engine propagation_engines.PropagationEngine[Grant, Identifier, Fields] + + // Fields below this point are used as state for the propagatorImpl, you're not meant to set them yourself. + + // _config is the config read from rolePropagation.propagators.. You're not meant to set this field + // when instantiating a propagatorImpl; it's set by init(). + _config *koanf.Koanf + + // _enable stores whether this propagator is enabled in the _config, from + // rolePropagation.propagators..enabled. + _enable bool + + // _timeout is the amount of time the propagator will be allowed to run during Propagate. It's read from the + // configuration at rolePropagation.propagators..timeout, with a default read from the config at + // rolePropagation.defaultTimeout. + _timeout time.Duration + + // _toleratedUsers is a set of users that we won't try to Remove on the remote for any reason. This can be + // helpful either for users that Sherlock doesn't manage or to protect against Sherlock being buggy. + // + // (Many years ago, an automated system for deactivating inactive Firecloud accounts went haywire and + // deactivated *everyone* except its equivalent of this list. Sherlock seeks to prevent such issues with the + // power of "writing tests" but we keep the guardrails that have worked in the past.) + _toleratedUsers []Identifier +} + +func (p propagatorImpl[Grant, Identifier, Fields]) Name() string { + return p.configKey +} diff --git a/sherlock/internal/role_propagation/propagator_consume_states_to_diff.go b/sherlock/internal/role_propagation/propagator_consume_states_to_diff.go new file mode 100644 index 000000000..b7fcc8399 --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_consume_states_to_diff.go @@ -0,0 +1,69 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" +) + +// consumeStatesToDiff returns a list of functions that, when called, will align the currentState with the +// desiredState. This function uses the state inputs as accumulators, so the inputs given to this function should not +// be used afterward. +// +// This function has a lot of loops that generate functions that will be called later. We explicitly initialize new +// variables to store the values of the loop variables so that the functions generated will capture the correct values. +// We just do this pattern everywhere in this function because it's always correct and it's easier to reason about. +func (p *propagatorImpl[Grant, Identifier, Fields]) consumeStatesToDiff( + ctx context.Context, + grant Grant, + currentState []intermediary_user.IntermediaryUser[Identifier, Fields], + desiredState map[uint]intermediary_user.IntermediaryUser[Identifier, Fields], +) (alignmentOperations []func() (string, error)) { + +currentlyGrantedUserLoop: + for _, unsafeCurrentlyGrantedUser := range currentState { + currentlyGrantedUser := unsafeCurrentlyGrantedUser + + // Seek match from desiredState + for unsafeDesiredSherlockUserID, unsafeDesiredUser := range desiredState { + desiredSherlockUserID := unsafeDesiredSherlockUserID + desiredUser := unsafeDesiredUser + + if currentlyGrantedUser.Identifier.EqualTo(desiredUser.Identifier) { + // Match! If fields are different we update; either way we move on to the next currently granted user. + // We actually remove the entry from desiredState so we know what's left over and needs to be added + // at the end. + if !currentlyGrantedUser.Fields.EqualTo(desiredUser.Fields) { + alignmentOperations = append(alignmentOperations, func() (string, error) { + return p.engine.Update(ctx, grant, desiredUser.Identifier, currentlyGrantedUser.Fields, desiredUser.Fields) + }) + } + + delete(desiredState, desiredSherlockUserID) + + continue currentlyGrantedUserLoop + } + } + + // No match from desiredState! Let's seek a match in the users we are configured to tolerate. + for _, toleratedUser := range p._toleratedUsers { + if currentlyGrantedUser.Identifier.EqualTo(toleratedUser) { + // Match! Let's move on to the next currently granted user, we'll leave this one alone. + continue currentlyGrantedUserLoop + } + } + + // No match in desiredState or toleratedUsers! Remove the grant from the currently granted user. + alignmentOperations = append(alignmentOperations, func() (string, error) { + return p.engine.Remove(ctx, grant, currentlyGrantedUser.Identifier) + }) + } + + // If there are any desired users left, add them. + for _, unsafeDesiredUser := range desiredState { + desiredUser := unsafeDesiredUser + alignmentOperations = append(alignmentOperations, func() (string, error) { + return p.engine.Add(ctx, grant, desiredUser.Identifier, desiredUser.Fields) + }) + } + return alignmentOperations +} diff --git a/sherlock/internal/role_propagation/propagator_consume_states_to_diff_test.go b/sherlock/internal/role_propagation/propagator_consume_states_to_diff_test.go new file mode 100644 index 000000000..57bb0bfd0 --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_consume_states_to_diff_test.go @@ -0,0 +1,99 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines/propagation_engines_mocks" + "testing" +) + +type testIdentifier struct { + identifier string +} + +func (t testIdentifier) EqualTo(other intermediary_user.Identifier) bool { + return t.identifier == other.(testIdentifier).identifier +} + +type testFields struct { + field string +} + +func (t testFields) EqualTo(other intermediary_user.Fields) bool { + return t.field == other.(testFields).field +} + +type testIntermediaryUser = intermediary_user.IntermediaryUser[testIdentifier, testFields] + +func Test_propagatorImpl_consumeStatesToDiff(t *testing.T) { + engine := propagation_engines_mocks.NewMockPropagationEngine[string, testIdentifier, testFields](t) + p := &propagatorImpl[string, testIdentifier, testFields]{ + engine: engine, + } + + ctx := context.Background() + grant := "grant" + currentState := make([]testIntermediaryUser, 0) + desiredState := make(map[uint]testIntermediaryUser) + + // User in both current and desired, no changes, expect nothing to be called + identifier1 := testIdentifier{"user1"} + fields1 := testFields{"field1"} + currentState = append(currentState, testIntermediaryUser{Identifier: identifier1, Fields: fields1}) + desiredState[1] = testIntermediaryUser{Identifier: identifier1, Fields: fields1} + + // User in both current and desired, fields differ, expect Update to be called + identifier2 := testIdentifier{"user2"} + fields2 := testFields{"field2"} + fields2Desired := testFields{"field2Desired"} + currentState = append(currentState, testIntermediaryUser{Identifier: identifier2, Fields: fields2}) + desiredState[2] = testIntermediaryUser{Identifier: identifier2, Fields: fields2Desired} + engine.EXPECT().Update(ctx, grant, identifier2, fields2, fields2Desired).Return("", nil).Once() + + // User in current, not in desired, expect Remove to be called + identifier3 := testIdentifier{"user3"} + fields3 := testFields{"field3"} + currentState = append(currentState, testIntermediaryUser{Identifier: identifier3, Fields: fields3}) + engine.EXPECT().Remove(ctx, grant, identifier3).Return("", nil).Once() + + // User in current, not in desired, but in tolerated, expect nothing to be called + identifier4 := testIdentifier{"user4"} + fields4 := testFields{"field4"} + currentState = append(currentState, testIntermediaryUser{Identifier: identifier4, Fields: fields4}) + p._toleratedUsers = append(p._toleratedUsers, identifier4) + + // User in tolerated, not in current or desired, expect nothing to be called + identifier5 := testIdentifier{"user5"} + p._toleratedUsers = append(p._toleratedUsers, identifier5) + + // User in desired, not in current, expect Add to be called + identifier6 := testIdentifier{"user6"} + fields6 := testFields{"field6"} + desiredState[6] = testIntermediaryUser{Identifier: identifier6, Fields: fields6} + engine.EXPECT().Add(ctx, grant, identifier6, fields6).Return("", nil).Once() + + alignmentOperations := p.consumeStatesToDiff(ctx, grant, currentState, desiredState) + for _, alignmentOperation := range alignmentOperations { + // These operations are pure mocks so there's no point to testing their return values, + // we're just calling the outputs so the mock observes the calls + _, _ = alignmentOperation() + } + + engine.AssertExpectations(t) +} + +func Test_propagatorImpl_consumeStatesToDiff_empty(t *testing.T) { + engine := propagation_engines_mocks.NewMockPropagationEngine[string, testIdentifier, testFields](t) + p := &propagatorImpl[string, testIdentifier, testFields]{ + engine: engine, + } + + // This is a sorta dumb test but we're checking that we don't somehow fail on empty inputs -- everything else + // is pretty thoroughly covered by the main test above + alignmentOperations := p.consumeStatesToDiff(context.Background(), "", nil, nil) + for _, alignmentOperation := range alignmentOperations { + _, _ = alignmentOperation() + } + + engine.AssertExpectations(t) +} diff --git a/sherlock/internal/role_propagation/propagator_init.go b/sherlock/internal/role_propagation/propagator_init.go new file mode 100644 index 000000000..4fc14c39b --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_init.go @@ -0,0 +1,51 @@ +package role_propagation + +import ( + "context" + "fmt" + "github.com/broadinstitute/sherlock/sherlock/internal/config" + "github.com/knadh/koanf" +) + +func (p *propagatorImpl[Grant, Identifier, Fields]) Init(ctx context.Context) error { + p._config = config.Config.Cut("rolePropagation.propagators." + p.configKey) + + p._enable = p._config.Bool("enable") + if !p._enable { + return nil + } + + p.initTimeout() + + if err := p.initToleratedUsers(); err != nil { + return err + } + + if err := p.engine.Init(ctx, p._config); err != nil { + return fmt.Errorf("failed to initialize engine for propagator %s: %w", p.configKey, err) + } + + return nil +} + +func (p *propagatorImpl[Grant, Identifier, Fields]) initTimeout() { + timeout := p._config.Duration("timeout") + if timeout == 0 { + timeout = config.Config.Duration("rolePropagation.defaultTimeout") + } + p._timeout = timeout +} + +func (p *propagatorImpl[Grant, Identifier, Fields]) initToleratedUsers() error { + if toleratedUsers := p._config.Slices("toleratedUsers"); len(toleratedUsers) > 0 { + p._toleratedUsers = make([]Identifier, 0, len(toleratedUsers)) + for index, unparsed := range toleratedUsers { + var tolerated Identifier + if err := unparsed.UnmarshalWithConf("", &tolerated, koanf.UnmarshalConf{Tag: "koanf"}); err != nil { + return fmt.Errorf("failed to unmarshal tolerated user at rolePropagation.propagators.%s.toleratedUsers[%d]: %w", p.configKey, index, err) + } + p._toleratedUsers = append(p._toleratedUsers, tolerated) + } + } + return nil +} diff --git a/sherlock/internal/role_propagation/propagator_init_test.go b/sherlock/internal/role_propagation/propagator_init_test.go new file mode 100644 index 000000000..c65410bfc --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_init_test.go @@ -0,0 +1,140 @@ +package role_propagation + +import ( + "context" + "github.com/broadinstitute/sherlock/sherlock/internal/config" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines/propagation_engines_mocks" + "github.com/knadh/koanf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "testing" +) + +func Test_propagatorImpl_Init(t *testing.T) { + config.LoadTestConfig() + + ctx := context.Background() + type testCase[Grant any, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + name string + p propagatorImpl[Grant, Identifier, Fields] + engineFunc func(c *propagation_engines_mocks.MockPropagationEngine[Grant, Identifier, Fields]) + wantErr bool + extraAssertions func(t *testing.T, p propagatorImpl[Grant, Identifier, Fields]) + } + tests := []testCase[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + { + name: "disabled", + p: propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "devFirecloudGroupTestDisabled", + }, + engineFunc: func(_ *propagation_engines_mocks.MockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + }, + wantErr: false, + extraAssertions: func(t *testing.T, p propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + assert.Falsef(t, p._enable, "expected propagator to be disabled") + }, + }, + { + name: "default", + p: propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "devFirecloudGroupTestDefault", + }, + engineFunc: func(c *propagation_engines_mocks.MockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + c.EXPECT().Init(ctx, mock.Anything).Return(nil) + }, + wantErr: false, + extraAssertions: func(t *testing.T, p propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + assert.Truef(t, p._enable, "expected propagator to be enabled") + assert.Equalf(t, config.Config.Duration("rolePropagation.defaultTimeout"), p._timeout, "expected timeout to be the default") + assert.Emptyf(t, p._toleratedUsers, "expected no tolerated users") + }, + }, + { + name: "error", + p: propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "devFirecloudGroupTestDefault", + }, + engineFunc: func(c *propagation_engines_mocks.MockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + c.EXPECT().Init(ctx, mock.Anything).Return(assert.AnError) + }, + wantErr: true, + }, + { + name: "config", + p: propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + configKey: "devFirecloudGroupTestConfig", + }, + engineFunc: func(c *propagation_engines_mocks.MockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + c.EXPECT().Init(ctx, mock.Anything).Return(nil) + }, + wantErr: false, + extraAssertions: func(t *testing.T, p propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]) { + assert.Truef(t, p._enable, "expected propagator to be enabled") + assert.Equalf(t, config.Config.Duration("rolePropagation.propagators.devFirecloudGroupTestConfig.timeout"), p._timeout, "expected timeout to be the configured value") + if assert.Lenf(t, p._toleratedUsers, 1, "expected one tolerated user") { + assert.Equalf(t, "tolerated@test.firecloud.org", p._toleratedUsers[0].Email, "expected the correct tolerated user") + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.engineFunc != nil { + mockEngine := propagation_engines_mocks.NewMockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields](t) + tt.engineFunc(mockEngine) + tt.p.engine = mockEngine + } + if err := tt.p.Init(ctx); (err != nil) != tt.wantErr { + t.Errorf("Init() error = %v, wantErr %v", err, tt.wantErr) + } else if tt.extraAssertions != nil { + tt.extraAssertions(t, tt.p) + } + }) + } +} + +func Test_propagatorImpl_initTimeout(t *testing.T) { + type testCase[Grant any, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + name string + p propagatorImpl[Grant, Identifier, Fields] + } + tests := []testCase[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.p.initTimeout() + }) + } +} + +type identifierWithInt struct { + Number int `koanf:"number"` +} + +func (_ identifierWithInt) EqualTo(_ intermediary_user.Identifier) bool { + panic("shouldn't be called") +} + +type blankFields struct{} + +func (_ blankFields) EqualTo(_ intermediary_user.Fields) bool { + panic("shouldn't be called") +} + +func Test_propagatorImpl_initToleratedUsers_error(t *testing.T) { + k := koanf.New(".") + require.NoError(t, k.Set("toleratedUsers", []any{ + map[string]any{ + "number": "definitely not a number", + }, + })) + p := propagatorImpl[string, identifierWithInt, blankFields]{ + _config: k, + } + + assert.Errorf(t, p.initToleratedUsers(), "expected an error") +} diff --git a/sherlock/internal/role_propagation/propagator_propagate.go b/sherlock/internal/role_propagation/propagator_propagate.go new file mode 100644 index 000000000..bc88ac647 --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_propagate.go @@ -0,0 +1,50 @@ +package role_propagation + +import ( + "context" + "fmt" + "github.com/broadinstitute/sherlock/sherlock/internal/models" +) + +func (p *propagatorImpl[Grant, Identifier, Fields]) Propagate(ctx context.Context, role models.Role) (results []string, errors []error) { + defer func() { + if r := recover(); r != nil { + errors = append(errors, fmt.Errorf("panic in %T during propagation: %v", p, r)) + } + }() + + if !p._enable { + return nil, nil + } + + timeoutCtx, cancel := context.WithTimeout(ctx, p._timeout) + defer cancel() + + shouldPropagate, grant := p.shouldPropagate(role) + if !shouldPropagate { + return nil, nil + } + + currentState, err := p.engine.LoadCurrentState(timeoutCtx, grant) + if err != nil { + return nil, []error{fmt.Errorf("failed to load current state for grant %v: %w", grant, err)} + } + + desiredState, err := p.engine.GenerateDesiredState(timeoutCtx, role.AssignmentsMap()) + if err != nil { + return nil, []error{fmt.Errorf("failed to generate desired state for grant %v: %w", grant, err)} + } + + alignmentOperations := p.consumeStatesToDiff(timeoutCtx, grant, currentState, desiredState) + + for _, alignmentOperation := range alignmentOperations { + result, err := alignmentOperation() + if err != nil { + errors = append(errors, err) + } else { + results = append(results, result) + } + } + + return results, errors +} diff --git a/sherlock/internal/role_propagation/propagator_propagate_test.go b/sherlock/internal/role_propagation/propagator_propagate_test.go new file mode 100644 index 000000000..59fe006c1 --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_propagate_test.go @@ -0,0 +1,195 @@ +package role_propagation + +import ( + "context" + "fmt" + "github.com/broadinstitute/sherlock/go-shared/pkg/utils" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines/propagation_engines_mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "slices" + "testing" + "time" +) + +func Test_propagatorImpl_Propagate_panic(t *testing.T) { + engine := propagation_engines_mocks.NewMockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields](t) + engine.EXPECT().LoadCurrentState(mock.Anything, mock.Anything).Panic("panic") + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + getGrant: func(role models.Role) *string { + return role.GrantsDevFirecloudGroup + }, + engine: engine, + _enable: true, + _timeout: time.Minute, + } + var results []string + var errors []error + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo("string"), + }, + }) + }) + assert.Empty(t, results) + if assert.Len(t, errors, 1) { + assert.ErrorContains(t, errors[0], "panic") + } +} + +func Test_propagatorImpl_Propagate_notEnabled(t *testing.T) { + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + _enable: false, + } + var results []string + var errors []error + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{}) + }) + assert.Empty(t, results) + assert.Empty(t, errors) +} + +func Test_propagatorImpl_Propagate_shouldNotPropagate(t *testing.T) { + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + getGrant: func(role models.Role) *string { + return role.GrantsDevFirecloudGroup + }, + _enable: true, + _timeout: time.Minute, + } + var results []string + var errors []error + t.Run("nil", func(t *testing.T) { + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: nil, + }, + }) + }) + assert.Empty(t, results) + assert.Empty(t, errors) + }) + t.Run("empty", func(t *testing.T) { + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo(""), + }, + }) + }) + assert.Empty(t, results) + assert.Empty(t, errors) + }) +} + +func Test_propagatorImpl_Propagate_failToLoadCurrent(t *testing.T) { + engine := propagation_engines_mocks.NewMockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields](t) + engine.EXPECT().LoadCurrentState(mock.Anything, mock.Anything). + Return(nil, assert.AnError) + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + getGrant: func(role models.Role) *string { + return role.GrantsDevFirecloudGroup + }, + engine: engine, + _enable: true, + _timeout: time.Minute, + } + var results []string + var errors []error + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo("string"), + }, + }) + }) + assert.Empty(t, results) + if assert.Len(t, errors, 1) { + assert.ErrorContains(t, errors[0], "failed to load current state for grant") + } +} + +func Test_propagatorImpl_Propagate_failToGenerateDesired(t *testing.T) { + engine := propagation_engines_mocks.NewMockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields](t) + engine.EXPECT().LoadCurrentState(mock.Anything, mock.Anything). + Return(nil, nil) + engine.EXPECT().GenerateDesiredState(mock.Anything, mock.Anything). + Return(nil, assert.AnError) + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + getGrant: func(role models.Role) *string { + return role.GrantsDevFirecloudGroup + }, + engine: engine, + _enable: true, + _timeout: time.Minute, + } + var results []string + var errors []error + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo("string"), + }, + }) + }) + assert.Empty(t, results) + if assert.Len(t, errors, 1) { + assert.ErrorContains(t, errors[0], "failed to generate desired state for grant") + } +} + +func Test_propagatorImpl_Propagate(t *testing.T) { + // consumeStatesToDiff is tested separately; we're not trying to exercise it here, just test that we + // call and handle it correctly + engine := propagation_engines_mocks.NewMockPropagationEngine[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields](t) + engine.EXPECT().LoadCurrentState(mock.Anything, mock.Anything). + Return([]intermediary_user.IntermediaryUser[propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + { + Identifier: propagation_engines.GoogleWorkspaceGroupIdentifier{Email: "a@example.com"}, + Fields: propagation_engines.GoogleWorkspaceGroupFields{}, + }, + }, nil) + engine.EXPECT().Remove(mock.Anything, "string", propagation_engines.GoogleWorkspaceGroupIdentifier{Email: "a@example.com"}). + Return("removed a", nil).Once() + engine.EXPECT().GenerateDesiredState(mock.Anything, mock.Anything). + Return(map[uint]intermediary_user.IntermediaryUser[propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + 1: { + Identifier: propagation_engines.GoogleWorkspaceGroupIdentifier{Email: "b@example.com"}, + Fields: propagation_engines.GoogleWorkspaceGroupFields{}, + }, + 2: { + Identifier: propagation_engines.GoogleWorkspaceGroupIdentifier{Email: "c@example.com"}, + Fields: propagation_engines.GoogleWorkspaceGroupFields{}, + }, + }, nil) + engine.EXPECT().Add(mock.Anything, "string", propagation_engines.GoogleWorkspaceGroupIdentifier{Email: "b@example.com"}, propagation_engines.GoogleWorkspaceGroupFields{}). + Return("added b", nil).Once() + engine.EXPECT().Add(mock.Anything, "string", propagation_engines.GoogleWorkspaceGroupIdentifier{Email: "c@example.com"}, propagation_engines.GoogleWorkspaceGroupFields{}). + Return("oh no", fmt.Errorf("failed to add c")).Once() + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + getGrant: func(role models.Role) *string { + return role.GrantsDevFirecloudGroup + }, + engine: engine, + _enable: true, + _timeout: time.Minute, + } + var results []string + var errors []error + assert.NotPanics(t, func() { + results, errors = p.Propagate(context.Background(), models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo("string"), + }, + }) + }) + slices.Sort(results) + assert.Equal(t, []string{"added b", "removed a"}, results) + assert.Equal(t, []error{fmt.Errorf("failed to add c")}, errors) +} diff --git a/sherlock/internal/role_propagation/propagator_should_propagate.go b/sherlock/internal/role_propagation/propagator_should_propagate.go new file mode 100644 index 000000000..31cc7918e --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_should_propagate.go @@ -0,0 +1,21 @@ +package role_propagation + +import ( + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "reflect" +) + +// shouldPropagate determines whether this propagator should act on the given role. +// +// It uses a tiny bit of reflection: if the grant is nil or the zero value of its type, we will not propagate. +// This is because we often explicitly store Go zero values in the database to represent "unset", because that's +// way easier for us to work with than null itself -- Gorm will ignore nulls by default to try to fit with Go's +// zero value semantics. +func (p *propagatorImpl[Grant, Identifier, Fields]) shouldPropagate(role models.Role) (shouldPropagate bool, grant Grant) { + grantPointer := p.getGrant(role) + if grantPointer == nil || reflect.ValueOf(*grantPointer).IsZero() { + return false, grant + } else { + return true, *grantPointer + } +} diff --git a/sherlock/internal/role_propagation/propagator_should_propagate_test.go b/sherlock/internal/role_propagation/propagator_should_propagate_test.go new file mode 100644 index 000000000..da0f2c600 --- /dev/null +++ b/sherlock/internal/role_propagation/propagator_should_propagate_test.go @@ -0,0 +1,72 @@ +package role_propagation + +import ( + "github.com/broadinstitute/sherlock/go-shared/pkg/utils" + "github.com/broadinstitute/sherlock/sherlock/internal/models" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/intermediary_user" + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/propagation_engines" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_propagatorImpl_shouldPropagate(t *testing.T) { + p := propagatorImpl[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + getGrant: func(role models.Role) *string { + return role.GrantsDevFirecloudGroup + }, + } + type args struct { + role models.Role + } + type testCase[Grant any, Identifier intermediary_user.Identifier, Fields intermediary_user.Fields] struct { + name string + args args + wantShouldPropagate bool + wantGrant Grant + } + tests := []testCase[string, propagation_engines.GoogleWorkspaceGroupIdentifier, propagation_engines.GoogleWorkspaceGroupFields]{ + { + name: "nil", + args: args{ + role: models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: nil, + }, + }, + }, + wantShouldPropagate: false, + wantGrant: "", + }, + { + name: "empty", + args: args{ + role: models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo(""), + }, + }, + }, + wantShouldPropagate: false, + wantGrant: "", + }, + { + name: "non-empty", + args: args{ + role: models.Role{ + RoleFields: models.RoleFields{ + GrantsDevFirecloudGroup: utils.PointerTo("string"), + }, + }, + }, + wantShouldPropagate: true, + wantGrant: "string", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotShouldPropagate, gotGrant := p.shouldPropagate(tt.args.role) + assert.Equalf(t, tt.wantShouldPropagate, gotShouldPropagate, "shouldPropagate(%v)", tt.args.role) + assert.Equalf(t, tt.wantGrant, gotGrant, "shouldPropagate(%v)", tt.args.role) + }) + } +} diff --git a/sherlock/internal/role_propagation/role_propagation_mocks/mock_propagator.go b/sherlock/internal/role_propagation/role_propagation_mocks/mock_propagator.go new file mode 100644 index 000000000..2b9248263 --- /dev/null +++ b/sherlock/internal/role_propagation/role_propagation_mocks/mock_propagator.go @@ -0,0 +1,177 @@ +// Code generated by mockery v2.32.4. DO NOT EDIT. + +package role_propagation_mocks + +import ( + context "context" + + models "github.com/broadinstitute/sherlock/sherlock/internal/models" + mock "github.com/stretchr/testify/mock" +) + +// MockPropagator is an autogenerated mock type for the propagator type +type MockPropagator struct { + mock.Mock +} + +type MockPropagator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPropagator) EXPECT() *MockPropagator_Expecter { + return &MockPropagator_Expecter{mock: &_m.Mock} +} + +// Init provides a mock function with given fields: ctx +func (_m *MockPropagator) Init(ctx context.Context) error { + ret := _m.Called(ctx) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPropagator_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init' +type MockPropagator_Init_Call struct { + *mock.Call +} + +// Init is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockPropagator_Expecter) Init(ctx interface{}) *MockPropagator_Init_Call { + return &MockPropagator_Init_Call{Call: _e.mock.On("Init", ctx)} +} + +func (_c *MockPropagator_Init_Call) Run(run func(ctx context.Context)) *MockPropagator_Init_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockPropagator_Init_Call) Return(_a0 error) *MockPropagator_Init_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPropagator_Init_Call) RunAndReturn(run func(context.Context) error) *MockPropagator_Init_Call { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with given fields: +func (_m *MockPropagator) Name() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockPropagator_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type MockPropagator_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *MockPropagator_Expecter) Name() *MockPropagator_Name_Call { + return &MockPropagator_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *MockPropagator_Name_Call) Run(run func()) *MockPropagator_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockPropagator_Name_Call) Return(_a0 string) *MockPropagator_Name_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPropagator_Name_Call) RunAndReturn(run func() string) *MockPropagator_Name_Call { + _c.Call.Return(run) + return _c +} + +// Propagate provides a mock function with given fields: ctx, role +func (_m *MockPropagator) Propagate(ctx context.Context, role models.Role) ([]string, []error) { + ret := _m.Called(ctx, role) + + var r0 []string + var r1 []error + if rf, ok := ret.Get(0).(func(context.Context, models.Role) ([]string, []error)); ok { + return rf(ctx, role) + } + if rf, ok := ret.Get(0).(func(context.Context, models.Role) []string); ok { + r0 = rf(ctx, role) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, models.Role) []error); ok { + r1 = rf(ctx, role) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]error) + } + } + + return r0, r1 +} + +// MockPropagator_Propagate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Propagate' +type MockPropagator_Propagate_Call struct { + *mock.Call +} + +// Propagate is a helper method to define mock.On call +// - ctx context.Context +// - role models.Role +func (_e *MockPropagator_Expecter) Propagate(ctx interface{}, role interface{}) *MockPropagator_Propagate_Call { + return &MockPropagator_Propagate_Call{Call: _e.mock.On("Propagate", ctx, role)} +} + +func (_c *MockPropagator_Propagate_Call) Run(run func(ctx context.Context, role models.Role)) *MockPropagator_Propagate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.Role)) + }) + return _c +} + +func (_c *MockPropagator_Propagate_Call) Return(results []string, errors []error) *MockPropagator_Propagate_Call { + _c.Call.Return(results, errors) + return _c +} + +func (_c *MockPropagator_Propagate_Call) RunAndReturn(run func(context.Context, models.Role) ([]string, []error)) *MockPropagator_Propagate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockPropagator creates a new instance of MockPropagator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPropagator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockPropagator { + mock := &MockPropagator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sherlock/internal/role_propagation/test_helpers.go b/sherlock/internal/role_propagation/test_helpers.go new file mode 100644 index 000000000..0235f7af6 --- /dev/null +++ b/sherlock/internal/role_propagation/test_helpers.go @@ -0,0 +1,24 @@ +package role_propagation + +import ( + "github.com/broadinstitute/sherlock/sherlock/internal/role_propagation/role_propagation_mocks" + "testing" +) + +func UseMockedPropagator(t *testing.T, config func(c *role_propagation_mocks.MockPropagator), callback func()) { + c := role_propagation_mocks.NewMockPropagator(t) + config(c) + useTestPropagators(t, []propagator{c}, callback) + c.AssertExpectations(t) +} + +func useTestPropagators(t *testing.T, testPropagators []propagator, callback func()) { + if t == nil { + // This just prevents this function from being called outside of tests. + panic("useTestPropagators must be called with a non-nil *testing.T") + } + temp := propagators + propagators = testPropagators + callback() + propagators = temp +}