diff --git a/api/authmethods/oidc_auth_method_attributes.gen.go b/api/authmethods/oidc_auth_method_attributes.gen.go index 1f95543779..431cf055ea 100644 --- a/api/authmethods/oidc_auth_method_attributes.gen.go +++ b/api/authmethods/oidc_auth_method_attributes.gen.go @@ -27,6 +27,8 @@ type OidcAuthMethodAttributes struct { DisableDiscoveredConfigValidation bool `json:"disable_discovered_config_validation,omitempty"` DryRun bool `json:"dry_run,omitempty"` Prompts []string `json:"prompts,omitempty"` + GoogleWorkspaceServiceAccountJson string `json:"google_workspace_service_account_json,omitempty"` + GoogleWorkspaceAdminEmail string `json:"google_workspace_admin_email,omitempty"` } func AttributesMapToOidcAuthMethodAttributes(in map[string]any) (*OidcAuthMethodAttributes, error) { diff --git a/api/authmethods/option.gen.go b/api/authmethods/option.gen.go index eb94db001f..6236b05dc9 100644 --- a/api/authmethods/option.gen.go +++ b/api/authmethods/option.gen.go @@ -587,6 +587,54 @@ func DefaultLdapAuthMethodEnableGroups() Option { } } +func WithOidcAuthMethodGoogleWorkspaceAdminEmail(inGoogleWorkspaceAdminEmail string) Option { + return func(o *options) { + raw, ok := o.postMap["attributes"] + if !ok { + raw = any(map[string]any{}) + } + val := raw.(map[string]any) + val["google_workspace_admin_email"] = inGoogleWorkspaceAdminEmail + o.postMap["attributes"] = val + } +} + +func DefaultOidcAuthMethodGoogleWorkspaceAdminEmail() Option { + return func(o *options) { + raw, ok := o.postMap["attributes"] + if !ok { + raw = any(map[string]any{}) + } + val := raw.(map[string]any) + val["google_workspace_admin_email"] = nil + o.postMap["attributes"] = val + } +} + +func WithOidcAuthMethodGoogleWorkspaceServiceAccountJson(inGoogleWorkspaceServiceAccountJson string) Option { + return func(o *options) { + raw, ok := o.postMap["attributes"] + if !ok { + raw = any(map[string]any{}) + } + val := raw.(map[string]any) + val["google_workspace_service_account_json"] = inGoogleWorkspaceServiceAccountJson + o.postMap["attributes"] = val + } +} + +func DefaultOidcAuthMethodGoogleWorkspaceServiceAccountJson() Option { + return func(o *options) { + raw, ok := o.postMap["attributes"] + if !ok { + raw = any(map[string]any{}) + } + val := raw.(map[string]any) + val["google_workspace_service_account_json"] = nil + o.postMap["attributes"] = val + } +} + func WithLdapAuthMethodGroupAttr(inGroupAttr string) Option { return func(o *options) { raw, ok := o.postMap["attributes"] diff --git a/go.mod b/go.mod index 1125182d62..d7d78a2636 100644 --- a/go.mod +++ b/go.mod @@ -107,6 +107,7 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.1 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect diff --git a/go.sum b/go.sum index 292f8aba70..36294e2f79 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go/compute v1.38.0 h1:MilCLYQW2m7Dku8hRIIKo4r0oKastlD74sSu16riYKs= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= diff --git a/internal/auth/oidc/auth_method.go b/internal/auth/oidc/auth_method.go index 2ffdbcc2d7..283c0c3779 100644 --- a/internal/auth/oidc/auth_method.go +++ b/internal/auth/oidc/auth_method.go @@ -75,15 +75,17 @@ func NewAuthMethod(ctx context.Context, scopeId string, clientId string, clientS a := &AuthMethod{ AuthMethod: &store.AuthMethod{ - ScopeId: scopeId, - Name: opts.withName, - Description: opts.withDescription, - OperationalState: string(opts.withOperationalState), - Issuer: u, - ClientId: clientId, - ClientSecret: string(clientSecret), - MaxAge: int32(opts.withMaxAge), - ClaimsScopes: opts.withClaimsScopes, + ScopeId: scopeId, + Name: opts.withName, + Description: opts.withDescription, + OperationalState: string(opts.withOperationalState), + Issuer: u, + ClientId: clientId, + ClientSecret: string(clientSecret), + MaxAge: int32(opts.withMaxAge), + ClaimsScopes: opts.withClaimsScopes, + GoogleWorkspaceServiceAccountJson: opts.withGoogleWorkspaceServiceAccountJson, + GoogleWorkspaceAdminEmail: opts.withGoogleWorkspaceAdminEmail, }, } if opts.withApiUrl != nil { @@ -166,6 +168,11 @@ func (am *AuthMethod) validate(ctx context.Context, caller errors.Op) error { if am.MaxAge < -1 { return errors.New(ctx, errors.InvalidParameter, caller, "max age cannot be less than -1") } + hasServiceAccountJson := am.GoogleWorkspaceServiceAccountJson != "" + hasAdminEmail := am.GoogleWorkspaceAdminEmail != "" + if hasServiceAccountJson != hasAdminEmail { + return errors.New(ctx, errors.InvalidParameter, caller, "google_workspace_service_account_json and google_workspace_admin_email must both be set or both be empty") + } return nil } @@ -179,9 +186,18 @@ func AllocAuthMethod() AuthMethod { // Clone an AuthMethod. func (am *AuthMethod) Clone() *AuthMethod { cp := proto.Clone(am.AuthMethod) - return &AuthMethod{ + cloned := &AuthMethod{ AuthMethod: cp.(*store.AuthMethod), } + // The Google Workspace fields use proto field numbers beyond the compiled + // binary descriptor, so proto.Clone does not copy them. Copy manually. + cloned.GoogleWorkspaceServiceAccountJson = am.GoogleWorkspaceServiceAccountJson + cloned.GoogleWorkspaceAdminEmail = am.GoogleWorkspaceAdminEmail + if len(am.CtGoogleWorkspaceServiceAccountJson) > 0 { + cloned.CtGoogleWorkspaceServiceAccountJson = make([]byte, len(am.CtGoogleWorkspaceServiceAccountJson)) + copy(cloned.CtGoogleWorkspaceServiceAccountJson, am.CtGoogleWorkspaceServiceAccountJson) + } + return cloned } // TableName returns the table name. diff --git a/internal/auth/oidc/google_workspace.go b/internal/auth/oidc/google_workspace.go new file mode 100644 index 0000000000..3fc9ccfe72 --- /dev/null +++ b/internal/auth/oidc/google_workspace.go @@ -0,0 +1,142 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/hashicorp/boundary/internal/errors" + "golang.org/x/oauth2/google" +) + +const ( + // googleDirectoryGroupsURL is the Admin SDK endpoint for listing a user's groups. + googleDirectoryGroupsURL = "https://admin.googleapis.com/admin/directory/v1/groups" + + // googleDirectoryScope is the read-only OAuth2 scope for the Admin SDK + // Directory API groups resource. + googleDirectoryScope = "https://www.googleapis.com/auth/admin.directory.group.readonly" +) + +// googleDirectoryGroupsResponse models the relevant fields from the Admin SDK +// Groups.list response. +type googleDirectoryGroupsResponse struct { + Groups []struct { + Email string `json:"email"` + } `json:"groups"` + NextPageToken string `json:"nextPageToken"` +} + +// googleHTTPClientFunc creates an *http.Client authenticated to call the Google +// Admin SDK. It is a package-level variable so tests can substitute a mock +// without making real OAuth2 token requests. +var googleHTTPClientFunc = defaultGoogleHTTPClient + +// defaultGoogleHTTPClient parses a service account JSON key and returns an +// HTTP client that impersonates adminEmail via domain-wide delegation. +func defaultGoogleHTTPClient(ctx context.Context, serviceAccountJSON, adminEmail string) (*http.Client, error) { + const op = "oidc.defaultGoogleHTTPClient" + conf, err := google.JWTConfigFromJSON([]byte(serviceAccountJSON), googleDirectoryScope) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to parse Google service account JSON")) + } + // Domain-wide delegation: impersonate the Workspace admin to call the + // Directory API on behalf of any user in the domain. + conf.Subject = adminEmail + return conf.Client(ctx), nil +} + +// fetchGoogleWorkspaceGroups calls the Google Admin SDK Directory API to +// return the email addresses of all Google Workspace groups that userEmail +// belongs to. +// +// serviceAccountJSON is the plaintext content of a Google service account JSON +// key file whose associated service account has been granted domain-wide +// delegation with the scope https://www.googleapis.com/auth/admin.directory.group.readonly. +// +// adminEmail is a Google Workspace admin email address that Boundary +// impersonates when calling the API. +// +// On error the function returns a non-nil error; callers should log the error +// and continue authentication without group claims rather than blocking login. +func fetchGoogleWorkspaceGroups(ctx context.Context, serviceAccountJSON, adminEmail, userEmail string) ([]string, error) { + const op = "oidc.fetchGoogleWorkspaceGroups" + if serviceAccountJSON == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing service account JSON") + } + if adminEmail == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing admin email") + } + if userEmail == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing user email") + } + + httpClient, err := googleHTTPClientFunc(ctx, serviceAccountJSON, adminEmail) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + return listGoogleGroups(ctx, httpClient, userEmail) +} + +// listGoogleGroups pages through the Admin SDK Groups.list endpoint and +// returns all group email addresses the user belongs to. It is separated from +// fetchGoogleWorkspaceGroups so tests can inject a pre-authenticated client +// directly without going through googleHTTPClientFunc. +func listGoogleGroups(ctx context.Context, httpClient *http.Client, userEmail string) ([]string, error) { + const op = "oidc.listGoogleGroups" + var allGroups []string + pageToken := "" + + for { + reqURL := fmt.Sprintf("%s?userKey=%s&maxResults=200", + googleDirectoryGroupsURL, url.QueryEscape(userEmail)) + if pageToken != "" { + reqURL += "&pageToken=" + url.QueryEscape(pageToken) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to build Directory API request")) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Directory API request failed")) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, errors.Wrap(ctx, readErr, op, errors.WithMsg("failed to read Directory API response body")) + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(ctx, errors.Unknown, op, + fmt.Sprintf("Directory API returned HTTP %d: %s", resp.StatusCode, body)) + } + + var result googleDirectoryGroupsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, errors.Wrap(ctx, err, op, errors.WithMsg("failed to decode Directory API response")) + } + + for _, g := range result.Groups { + if g.Email != "" { + allGroups = append(allGroups, g.Email) + } + } + + if result.NextPageToken == "" { + break + } + pageToken = result.NextPageToken + } + + return allGroups, nil +} diff --git a/internal/auth/oidc/google_workspace_test.go b/internal/auth/oidc/google_workspace_test.go new file mode 100644 index 0000000000..2841a93c03 --- /dev/null +++ b/internal/auth/oidc/google_workspace_test.go @@ -0,0 +1,336 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hashicorp/boundary/internal/auth/oidc/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockDirectoryServer creates a test HTTP server that mimics the Google Admin +// SDK Directory API Groups.list endpoint. It returns the provided groups for +// any userKey and requires no authentication (the test bypasses the OAuth2 +// layer by injecting a pre-configured http.Client directly via listGoogleGroups). +func mockDirectoryServer(t *testing.T, responses []googleDirectoryGroupsResponse) *httptest.Server { + t.Helper() + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if callCount >= len(responses) { + t.Errorf("unexpected extra request #%d to mock directory server", callCount+1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + return + } + resp := responses[callCount] + callCount++ + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("failed to encode mock response: %v", err) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// rewriteGroupsURL returns an http.Client whose transport rewrites requests +// destined for googleDirectoryGroupsURL to the given mock server URL instead, +// so we can test listGoogleGroups against a local server. +func clientPointingAt(mockURL string) *http.Client { + return &http.Client{ + Transport: &urlRewriteTransport{targetBase: mockURL}, + } +} + +type urlRewriteTransport struct { + targetBase string +} + +func (t *urlRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Replace the scheme+host with the mock server's, preserving path+query. + clone := req.Clone(req.Context()) + clone.URL.Scheme = "http" + base := strings.TrimRight(t.targetBase, "/") + // Strip the real host prefix so the path hits the mock server root. + clone.URL.Host = strings.TrimPrefix(base, "http://") + clone.URL.Path = "/" + return http.DefaultTransport.RoundTrip(clone) +} + +func TestListGoogleGroups_SinglePage(t *testing.T) { + t.Parallel() + ctx := context.Background() + + want := []string{"eng@example.com", "infra@example.com"} + srv := mockDirectoryServer(t, []googleDirectoryGroupsResponse{ + { + Groups: []struct { + Email string `json:"email"` + }{ + {Email: "eng@example.com"}, + {Email: "infra@example.com"}, + }, + }, + }) + + got, err := listGoogleGroups(ctx, clientPointingAt(srv.URL), "alice@example.com") + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestListGoogleGroups_Pagination(t *testing.T) { + t.Parallel() + ctx := context.Background() + + srv := mockDirectoryServer(t, []googleDirectoryGroupsResponse{ + { + Groups: []struct { + Email string `json:"email"` + }{ + {Email: "eng@example.com"}, + }, + NextPageToken: "token-page-2", + }, + { + Groups: []struct { + Email string `json:"email"` + }{ + {Email: "infra@example.com"}, + {Email: "security@example.com"}, + }, + }, + }) + + got, err := listGoogleGroups(ctx, clientPointingAt(srv.URL), "alice@example.com") + require.NoError(t, err) + assert.Equal(t, []string{"eng@example.com", "infra@example.com", "security@example.com"}, got) +} + +func TestListGoogleGroups_EmptyMembership(t *testing.T) { + t.Parallel() + ctx := context.Background() + + srv := mockDirectoryServer(t, []googleDirectoryGroupsResponse{ + {}, // no groups, no next page token + }) + + got, err := listGoogleGroups(ctx, clientPointingAt(srv.URL), "alice@example.com") + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestListGoogleGroups_APIError(t *testing.T) { + t.Parallel() + ctx := context.Background() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error": {"code": 403, "message": "Not authorized"}}`, http.StatusForbidden) + })) + t.Cleanup(srv.Close) + + _, err := listGoogleGroups(ctx, clientPointingAt(srv.URL), "alice@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "403") +} + +func TestListGoogleGroups_SkipsEmptyEmailGroups(t *testing.T) { + t.Parallel() + ctx := context.Background() + + srv := mockDirectoryServer(t, []googleDirectoryGroupsResponse{ + { + Groups: []struct { + Email string `json:"email"` + }{ + {Email: "eng@example.com"}, + {Email: ""}, + {Email: "infra@example.com"}, + }, + }, + }) + + got, err := listGoogleGroups(ctx, clientPointingAt(srv.URL), "alice@example.com") + require.NoError(t, err) + assert.Equal(t, []string{"eng@example.com", "infra@example.com"}, got) +} + +func TestFetchGoogleWorkspaceGroups_ValidationErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + + cases := []struct { + name string + serviceAccountJSON string + adminEmail string + userEmail string + wantErr string + }{ + { + name: "missing service account JSON", + serviceAccountJSON: "", + adminEmail: "admin@example.com", + userEmail: "alice@example.com", + wantErr: "missing service account JSON", + }, + { + name: "missing admin email", + serviceAccountJSON: `{"type":"service_account"}`, + adminEmail: "", + userEmail: "alice@example.com", + wantErr: "missing admin email", + }, + { + name: "missing user email", + serviceAccountJSON: `{"type":"service_account"}`, + adminEmail: "admin@example.com", + userEmail: "", + wantErr: "missing user email", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := fetchGoogleWorkspaceGroups(ctx, tc.serviceAccountJSON, tc.adminEmail, tc.userEmail) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// TestFetchGoogleWorkspaceGroups_MockHTTPClient verifies that +// fetchGoogleWorkspaceGroups plumbs the HTTP client through to listGoogleGroups +// correctly by injecting a mock googleHTTPClientFunc. +func TestFetchGoogleWorkspaceGroups_MockHTTPClient(t *testing.T) { + t.Parallel() + ctx := context.Background() + + want := []string{"eng@example.com"} + srv := mockDirectoryServer(t, []googleDirectoryGroupsResponse{ + { + Groups: []struct { + Email string `json:"email"` + }{ + {Email: "eng@example.com"}, + }, + }, + }) + + // Swap out the package-level HTTP client factory to return a client that + // points at our mock server instead of calling real Google OAuth2. + orig := googleHTTPClientFunc + t.Cleanup(func() { googleHTTPClientFunc = orig }) + googleHTTPClientFunc = func(_ context.Context, _, _ string) (*http.Client, error) { + return clientPointingAt(srv.URL), nil + } + + got, err := fetchGoogleWorkspaceGroups(ctx, `{"type":"service_account"}`, "admin@example.com", "alice@example.com") + require.NoError(t, err) + assert.Equal(t, want, got) +} + +// TestFetchGoogleWorkspaceGroups_HTTPClientError verifies that errors from the +// HTTP client factory propagate correctly. +func TestFetchGoogleWorkspaceGroups_HTTPClientError(t *testing.T) { + t.Parallel() + ctx := context.Background() + + orig := googleHTTPClientFunc + t.Cleanup(func() { googleHTTPClientFunc = orig }) + googleHTTPClientFunc = func(_ context.Context, _, _ string) (*http.Client, error) { + return nil, fmt.Errorf("credential parse error") + } + + _, err := fetchGoogleWorkspaceGroups(ctx, `{"type":"service_account"}`, "admin@example.com", "alice@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "credential parse error") +} + +func TestAuthMethod_GoogleWorkspaceValidation(t *testing.T) { + t.Parallel() + ctx := context.Background() + + cases := []struct { + name string + serviceAccount string + adminEmail string + wantErr bool + }{ + { + name: "both set is valid", + serviceAccount: `{"type":"service_account"}`, + adminEmail: "admin@example.com", + wantErr: false, + }, + { + name: "both empty is valid", + serviceAccount: "", + adminEmail: "", + wantErr: false, + }, + { + name: "only service account set is invalid", + serviceAccount: `{"type":"service_account"}`, + adminEmail: "", + wantErr: true, + }, + { + name: "only admin email set is invalid", + serviceAccount: "", + adminEmail: "admin@example.com", + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + am := &AuthMethod{ + AuthMethod: &store.AuthMethod{ + ScopeId: "o_1234567890", + OperationalState: string(InactiveState), + GoogleWorkspaceServiceAccountJson: tc.serviceAccount, + GoogleWorkspaceAdminEmail: tc.adminEmail, + }, + } + err := am.validate(ctx, "test") + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "google_workspace_service_account_json and google_workspace_admin_email must both be set or both be empty") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAuthMethod_Clone_GoogleWorkspaceFields(t *testing.T) { + t.Parallel() + + am := &AuthMethod{ + AuthMethod: &store.AuthMethod{ + PublicId: "amoidc_test1234", + ScopeId: "o_1234567890", + GoogleWorkspaceServiceAccountJson: `{"type":"service_account"}`, + GoogleWorkspaceAdminEmail: "admin@example.com", + CtGoogleWorkspaceServiceAccountJson: []byte("encrypted-bytes"), + }, + } + + cloned := am.Clone() + assert.Equal(t, am.GoogleWorkspaceServiceAccountJson, cloned.GoogleWorkspaceServiceAccountJson) + assert.Equal(t, am.GoogleWorkspaceAdminEmail, cloned.GoogleWorkspaceAdminEmail) + assert.Equal(t, am.CtGoogleWorkspaceServiceAccountJson, cloned.CtGoogleWorkspaceServiceAccountJson) + + // Verify the clone is a deep copy: mutating the original doesn't affect the clone. + am.GoogleWorkspaceAdminEmail = "other@example.com" + am.CtGoogleWorkspaceServiceAccountJson[0] = 'X' + assert.Equal(t, "admin@example.com", cloned.GoogleWorkspaceAdminEmail) + assert.Equal(t, byte('e'), cloned.CtGoogleWorkspaceServiceAccountJson[0]) +} diff --git a/internal/auth/oidc/options.go b/internal/auth/oidc/options.go index 33968de503..883151e887 100644 --- a/internal/auth/oidc/options.go +++ b/internal/auth/oidc/options.go @@ -25,32 +25,34 @@ type Option func(*options) // options = how options are represented type options struct { - withName string - withDescription string - withLimit int - withMaxAge int - withApiUrl *url.URL - withCertificates []*x509.Certificate - withAudClaims []string - withSigningAlgs []Alg - withClaimsScopes []string - withPrompts []PromptParam - withEmail string - withFullName string - withOrderByCreateTime bool - ascending bool - withUnauthenticatedUser bool - withForce bool - withDryRun bool - withAuthMethod *AuthMethod - withPublicId string - withRoundtripPayload string - withKeyId string - withIssuer *url.URL - withOperationalState AuthMethodState - withAccountClaimMap map[string]AccountToClaim - withReader db.Reader - withStartPageAfterItem pagination.Item + withName string + withDescription string + withLimit int + withMaxAge int + withApiUrl *url.URL + withCertificates []*x509.Certificate + withAudClaims []string + withSigningAlgs []Alg + withClaimsScopes []string + withPrompts []PromptParam + withEmail string + withFullName string + withOrderByCreateTime bool + ascending bool + withUnauthenticatedUser bool + withForce bool + withDryRun bool + withAuthMethod *AuthMethod + withPublicId string + withRoundtripPayload string + withKeyId string + withIssuer *url.URL + withOperationalState AuthMethodState + withAccountClaimMap map[string]AccountToClaim + withReader db.Reader + withStartPageAfterItem pagination.Item + withGoogleWorkspaceServiceAccountJson string + withGoogleWorkspaceAdminEmail string } func getDefaultOptions() options { @@ -250,3 +252,22 @@ func WithStartPageAfterItem(item pagination.Item) Option { o.withStartPageAfterItem = item } } + +// WithGoogleWorkspaceServiceAccountJson provides a Google service account JSON +// key (plaintext). When paired with WithGoogleWorkspaceAdminEmail, Boundary +// will call the Google Admin SDK Directory API after OIDC token exchange to +// fetch the user's group memberships and inject them into userinfo claims. +func WithGoogleWorkspaceServiceAccountJson(json string) Option { + return func(o *options) { + o.withGoogleWorkspaceServiceAccountJson = json + } +} + +// WithGoogleWorkspaceAdminEmail provides the Google Workspace admin email that +// Boundary impersonates (via domain-wide delegation) when calling the Directory +// API. Must be paired with WithGoogleWorkspaceServiceAccountJson. +func WithGoogleWorkspaceAdminEmail(email string) Option { + return func(o *options) { + o.withGoogleWorkspaceAdminEmail = email + } +} diff --git a/internal/auth/oidc/service_callback.go b/internal/auth/oidc/service_callback.go index 5d22afc6cb..513773b0a7 100644 --- a/internal/auth/oidc/service_callback.go +++ b/internal/auth/oidc/service_callback.go @@ -187,6 +187,26 @@ func Callback( } } + // If the auth method has Google Workspace Directory API credentials, fetch + // the user's group memberships server-side and inject them into userinfo + // claims before managed-group filters are evaluated. A failure here is + // non-fatal: we log it and proceed with whatever claims are already present + // so that authentication is never blocked by Directory API unavailability. + if am.GoogleWorkspaceServiceAccountJson != "" && am.GoogleWorkspaceAdminEmail != "" { + userEmail, _ := userInfoClaims["email"].(string) + if userEmail == "" { + userEmail, _ = idTkClaims["email"].(string) + } + if userEmail != "" { + groups, gwErr := fetchGoogleWorkspaceGroups(ctx, am.GoogleWorkspaceServiceAccountJson, am.GoogleWorkspaceAdminEmail, userEmail) + if gwErr != nil { + event.WriteError(ctx, op, gwErr, event.WithInfoMsg("google workspace group fetch failed; proceeding without group claims")) + } else { + userInfoClaims["groups"] = groups + } + } + } + acct, err := r.upsertAccount(ctx, am, idTkClaims, userInfoClaims) if err != nil { return "", errors.Wrap(ctx, err, op) diff --git a/internal/auth/oidc/store/oidc.pb.go b/internal/auth/oidc/store/oidc.pb.go index 162d216963..90c74fc81c 100644 --- a/internal/auth/oidc/store/oidc.pb.go +++ b/internal/auth/oidc/store/oidc.pb.go @@ -129,9 +129,23 @@ type AuthMethod struct { // These are Value Objects that will be stored as Prompt messages, // and are operatated on as a complete set. // @inject_tag: `gorm:"-"` - Prompts []string `protobuf:"bytes,220,rep,name=prompts,proto3" json:"prompts,omitempty" gorm:"-"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Prompts []string `protobuf:"bytes,220,rep,name=prompts,proto3" json:"prompts,omitempty" gorm:"-"` + // ct_google_workspace_service_account_json is the encrypted Google service + // account JSON key used to call the Admin SDK Directory API. Stored as the + // column google_workspace_service_account_json in the database. + // @inject_tag: `gorm:"column:google_workspace_service_account_json;default:null" wrapping:"ct,google_workspace_service_account_json"` + CtGoogleWorkspaceServiceAccountJson []byte `protobuf:"bytes,230,opt,name=ct_google_workspace_service_account_json,json=ctGoogleWorkspaceServiceAccountJson,proto3" json:"ct_google_workspace_service_account_json,omitempty" gorm:"column:google_workspace_service_account_json;default:null" wrapping:"ct,google_workspace_service_account_json"` + // google_workspace_service_account_json is the plaintext service account + // JSON key. Not stored in the database. + // @inject_tag: `gorm:"-" wrapping:"pt,google_workspace_service_account_json"` + GoogleWorkspaceServiceAccountJson string `protobuf:"bytes,240,opt,name=google_workspace_service_account_json,json=googleWorkspaceServiceAccountJson,proto3" json:"google_workspace_service_account_json,omitempty" gorm:"-" wrapping:"pt,google_workspace_service_account_json"` + // google_workspace_admin_email is a Google Workspace admin email address + // that Boundary impersonates (via domain-wide delegation) when calling the + // Directory API to list a user's group memberships. + // @inject_tag: `gorm:"default:null"` + GoogleWorkspaceAdminEmail string `protobuf:"bytes,250,opt,name=google_workspace_admin_email,json=googleWorkspaceAdminEmail,proto3" json:"google_workspace_admin_email,omitempty" gorm:"default:null"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AuthMethod) Reset() { @@ -332,6 +346,27 @@ func (x *AuthMethod) GetPrompts() []string { return nil } +func (x *AuthMethod) GetCtGoogleWorkspaceServiceAccountJson() []byte { + if x != nil { + return x.CtGoogleWorkspaceServiceAccountJson + } + return nil +} + +func (x *AuthMethod) GetGoogleWorkspaceServiceAccountJson() string { + if x != nil { + return x.GoogleWorkspaceServiceAccountJson + } + return "" +} + +func (x *AuthMethod) GetGoogleWorkspaceAdminEmail() string { + if x != nil { + return x.GoogleWorkspaceAdminEmail + } + return "" +} + // Account represents an OIDC account // the scope_id column is not included here as it is used only to ensure // data integrity in the database between iam users and auth methods. @@ -1099,7 +1134,7 @@ var File_controller_storage_auth_oidc_store_v1_oidc_proto protoreflect.FileDescr const file_controller_storage_auth_oidc_store_v1_oidc_proto_rawDesc = "" + "\n" + - "0controller/storage/auth/oidc/store/v1/oidc.proto\x12%controller.storage.auth.oidc.store.v1\x1a*controller/custom_options/v1/options.proto\x1a/controller/storage/timestamp/v1/timestamp.proto\"\xc1\v\n" + + "0controller/storage/auth/oidc/store/v1/oidc.proto\x12%controller.storage.auth.oidc.store.v1\x1a*controller/custom_options/v1/options.proto\x1a/controller/storage/timestamp/v1/timestamp.proto\"\xd5\x0e\n" + "\n" + "AuthMethod\x12\x1b\n" + "\tpublic_id\x18\n" + @@ -1142,7 +1177,12 @@ const file_controller_storage_auth_oidc_store_v1_oidc_proto_rawDesc = "" + "\x12account_claim_maps\x18\xd2\x01 \x03(\tB5\xc2\xdd)1\n" + "\x10AccountClaimMaps\x12\x1dattributes.account_claim_mapsR\x10accountClaimMaps\x12<\n" + "\aprompts\x18\xdc\x01 \x03(\tB!\xc2\xdd)\x1d\n" + - "\aPrompts\x12\x12attributes.promptsR\aprompts\"\x9a\x04\n" + + "\aPrompts\x12\x12attributes.promptsR\aprompts\x12V\n" + + "(ct_google_workspace_service_account_json\x18\xe6\x01 \x01(\fR#ctGoogleWorkspaceServiceAccountJson\x12\xac\x01\n" + + "%google_workspace_service_account_json\x18\xf0\x01 \x01(\tBY\xc2\xdd)U\n" + + "!GoogleWorkspaceServiceAccountJson\x120attributes.google_workspace_service_account_jsonR!googleWorkspaceServiceAccountJson\x12\x8a\x01\n" + + "\x1cgoogle_workspace_admin_email\x18\xfa\x01 \x01(\tBH\xc2\xdd)D\n" + + "\x19GoogleWorkspaceAdminEmail\x12'attributes.google_workspace_admin_emailR\x19googleWorkspaceAdminEmail\"\x9a\x04\n" + "\aAccount\x12\x1b\n" + "\tpublic_id\x18\n" + " \x01(\tR\bpublicId\x12K\n" + diff --git a/internal/cmd/commands/authmethodscmd/oidc_funcs.go b/internal/cmd/commands/authmethodscmd/oidc_funcs.go index ae9845fb8f..aee40045de 100644 --- a/internal/cmd/commands/authmethodscmd/oidc_funcs.go +++ b/internal/cmd/commands/authmethodscmd/oidc_funcs.go @@ -20,20 +20,22 @@ func init() { } type extraOidcCmdVars struct { - flagState string - flagIssuer string - flagClientId string - flagClientSecret string - flagMaxAgeSeconds string - flagApiUrlPrefix string - flagSigningAlgorithms []string - flagIdpCaCerts []string - flagAllowedAudiences []string - flagClaimsScopes []string - flagAccountClaimMaps []string - flagDisableDiscoveredConfigValidation bool - flagDryRun bool - flagPrompts []string + flagState string + flagIssuer string + flagClientId string + flagClientSecret string + flagMaxAgeSeconds string + flagApiUrlPrefix string + flagSigningAlgorithms []string + flagIdpCaCerts []string + flagAllowedAudiences []string + flagClaimsScopes []string + flagAccountClaimMaps []string + flagDisableDiscoveredConfigValidation bool + flagDryRun bool + flagPrompts []string + flagGoogleWorkspaceServiceAccountJson string + flagGoogleWorkspaceAdminEmail string } const ( @@ -49,9 +51,11 @@ const ( claimsScopes = "claims-scopes" accountClaimMaps = "account-claim-maps" stateFlagName = "state" - disableDiscoveredConfigValidationFlagName = "disable-discovered-config-validation" - dryRunFlagName = "dry-run" - promptsFlagName = "prompts" + disableDiscoveredConfigValidationFlagName = "disable-discovered-config-validation" + dryRunFlagName = "dry-run" + promptsFlagName = "prompts" + googleWorkspaceServiceAccountJsonFlagName = "google-workspace-service-account-json" + googleWorkspaceAdminEmailFlagName = "google-workspace-admin-email" ) func extraOidcActionsFlagsMapFuncImpl() map[string][]string { @@ -68,6 +72,8 @@ func extraOidcActionsFlagsMapFuncImpl() map[string][]string { claimsScopes, accountClaimMaps, promptsFlagName, + googleWorkspaceServiceAccountJsonFlagName, + googleWorkspaceAdminEmailFlagName, }, "change-state": { idFlagName, @@ -168,6 +174,18 @@ func extraOidcFlagsFuncImpl(c *OidcCommand, set *base.FlagSets, _ *base.FlagSet) Target: &c.flagPrompts, Usage: "The optional prompt parameter that can be included in the authentication request to control the behavior of the authentication flow.", }) + case googleWorkspaceServiceAccountJsonFlagName: + f.StringVar(&base.StringVar{ + Name: googleWorkspaceServiceAccountJsonFlagName, + Target: &c.flagGoogleWorkspaceServiceAccountJson, + Usage: "The JSON key of a Google service account that has domain-wide delegation with the Admin SDK Directory API read scope. When set together with -google-workspace-admin-email, Boundary fetches the user's Google Workspace group memberships at login time and makes them available to managed-group filters as \"/userinfo/groups\". Write-only: the value is encrypted at rest and never returned by the API. Set to 'null' to clear.", + }) + case googleWorkspaceAdminEmailFlagName: + f.StringVar(&base.StringVar{ + Name: googleWorkspaceAdminEmailFlagName, + Target: &c.flagGoogleWorkspaceAdminEmail, + Usage: "The email address of a Google Workspace admin that Boundary impersonates (via domain-wide delegation) when calling the Directory API. Must be set together with -google-workspace-service-account-json. Set to 'null' to clear.", + }) } } } @@ -299,6 +317,20 @@ func extraOidcFlagHandlingFuncImpl(c *OidcCommand, f *base.FlagSets, opts *[]aut default: *opts = append(*opts, authmethods.WithOidcAuthMethodPrompts(c.flagPrompts)) } + switch c.flagGoogleWorkspaceServiceAccountJson { + case "": + case "null": + *opts = append(*opts, authmethods.DefaultOidcAuthMethodGoogleWorkspaceServiceAccountJson()) + default: + *opts = append(*opts, authmethods.WithOidcAuthMethodGoogleWorkspaceServiceAccountJson(c.flagGoogleWorkspaceServiceAccountJson)) + } + switch c.flagGoogleWorkspaceAdminEmail { + case "": + case "null": + *opts = append(*opts, authmethods.DefaultOidcAuthMethodGoogleWorkspaceAdminEmail()) + default: + *opts = append(*opts, authmethods.WithOidcAuthMethodGoogleWorkspaceAdminEmail(c.flagGoogleWorkspaceAdminEmail)) + } return true } diff --git a/internal/daemon/controller/handlers/authmethods/oidc.go b/internal/daemon/controller/handlers/authmethods/oidc.go index 6bbbe74d33..d9703598e5 100644 --- a/internal/daemon/controller/handlers/authmethods/oidc.go +++ b/internal/daemon/controller/handlers/authmethods/oidc.go @@ -45,9 +45,11 @@ const ( disableDiscoveredConfigValidationField = "attributes.disable_discovered_config_validation" roundtripPayloadAttributesField = "attributes.roundtrip_payload" codeField = "attributes.code" - claimsScopesField = "attributes.claims_scopes" - accountClaimMapsField = "attributes.account_claim_maps" - promptsField = "attributes.prompts" + claimsScopesField = "attributes.claims_scopes" + accountClaimMapsField = "attributes.account_claim_maps" + promptsField = "attributes.prompts" + googleWorkspaceServiceAccountJsonField = "attributes.google_workspace_service_account_json" + googleWorkspaceAdminEmailField = "attributes.google_workspace_admin_email" ) var oidcMaskManager handlers.MaskManager @@ -485,6 +487,13 @@ func toStorageOidcAuthMethod(ctx context.Context, scopeId string, in *pb.AuthMet opts = append(opts, oidc.WithClaimsScopes(attrs.GetClaimsScopes()...)) } + if v := strings.TrimSpace(attrs.GetGoogleWorkspaceServiceAccountJson().GetValue()); v != "" { + opts = append(opts, oidc.WithGoogleWorkspaceServiceAccountJson(v)) + } + if v := strings.TrimSpace(attrs.GetGoogleWorkspaceAdminEmail().GetValue()); v != "" { + opts = append(opts, oidc.WithGoogleWorkspaceAdminEmail(v)) + } + if len(attrs.GetAccountClaimMaps()) > 0 { claimsMap := make(map[string]oidc.AccountToClaim, len(attrs.GetAccountClaimMaps())) for _, v := range attrs.GetAccountClaimMaps() { diff --git a/internal/db/schema/migrations/oss/postgres/100/02_oidc_google_workspace.up.sql b/internal/db/schema/migrations/oss/postgres/100/02_oidc_google_workspace.up.sql new file mode 100644 index 0000000000..cf31186e92 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/100/02_oidc_google_workspace.up.sql @@ -0,0 +1,25 @@ +-- Copyright IBM Corp. 2020, 2026 +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + -- Add Google Workspace Directory API configuration to OIDC auth methods. + -- When google_workspace_service_account_json (encrypted) and + -- google_workspace_admin_email are both set, Boundary performs a server-side + -- call to the Google Admin SDK Directory API after OIDC token exchange to + -- fetch the authenticating user's group memberships. Those group emails are + -- injected into the userinfo claims as "groups" before managed-group filters + -- are evaluated, enabling filters such as: + -- "engineering@example.com" in "/userinfo/groups" + alter table auth_oidc_method + add column google_workspace_service_account_json bytea, + add column google_workspace_admin_email text; + + -- Constraint: both fields must be set together or both null. + alter table auth_oidc_method + add constraint google_workspace_config_both_or_neither + check ( + (google_workspace_service_account_json is null) = (google_workspace_admin_email is null) + ); + +commit; diff --git a/internal/gen/controller.swagger.json b/internal/gen/controller.swagger.json index ba4aae3e5c..e320a704a9 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -5947,7 +5947,7 @@ "min_login_name_length": 10, "min_password_length": 16 }, - "description": "The attributes that are applicable for the specific auth method type. The schema of this field depends on the type of the auth method that you create want to create.\nFor password auth methods, the parameters are:\n```json\n{\n \"min_login_name_length\": \"min_login_name_length\",\n \"min_password_length\": \"min_password_length\"\n}\n```\nFor OIDC auth methods, the parameters are:\n```json\n{\n \"issuer\": \"issuer\",\n \"client_id\": \"client_id\",\n \"client_secret\": \"client_secret\",\n \"max_age\": 3600,\n \"signing_algorithms\": [],\n \"api_url_prefix\": \"api_url_prefix\",\n \"idp_ca_certs\": [],\n \"allowed_audiences\": [],\n \"claims_scopes\": [],\n \"account_claim_maps\": [],\n \"disable_discovered_config_validation\": false,\n \"prompts\": []\n}\n```\nFor LDAP auth methods, the parameters are:\n```json\n{\n \"start_tls\": false,\n \"insecure_tls\": false,\n \"discover_dn\": false,\n \"anon_group_search\": false,\n \"upn_domain\": \"upn_domain\",\n \"urls\": [],\n \"user_dn\": \"user_dn\",\n \"user_attr\": \"user_attr\",\n \"user_filter\": \"user_filter\",\n \"enable_groups\": false,\n \"group_dn\": \"group_dn\",\n \"group_attr\": \"group_attr\",\n \"group_filter\": \"group_filter\",\n \"certificates\": [],\n \"client_certificate\": \"client_certificate\",\n \"client_certificate_key\": \"client_certificate_key\",\n \"bind_dn\": \"bind_dn\",\n \"bind_password\": \"bind_password\",\n \"use_token_groups\": false,\n \"account_attribute_maps\": [],\n \"maximum_page_size\": 1000,\n \"dereference_aliases\": \"never\"\n}\n```\n" + "description": "The attributes that are applicable for the specific auth method type. The schema of this field depends on the type of the auth method that you create want to create.\nFor password auth methods, the parameters are:\n```json\n{\n \"min_login_name_length\": \"min_login_name_length\",\n \"min_password_length\": \"min_password_length\"\n}\n```\nFor OIDC auth methods, the parameters are:\n```json\n{\n \"issuer\": \"issuer\",\n \"client_id\": \"client_id\",\n \"client_secret\": \"client_secret\",\n \"max_age\": 3600,\n \"signing_algorithms\": [],\n \"api_url_prefix\": \"api_url_prefix\",\n \"idp_ca_certs\": [],\n \"allowed_audiences\": [],\n \"claims_scopes\": [],\n \"account_claim_maps\": [],\n \"disable_discovered_config_validation\": false,\n \"prompts\": [],\n \"google_workspace_service_account_json\": \"google_workspace_service_account_json\",\n \"google_workspace_admin_email\": \"google_workspace_admin_email\"\n}\n```\nFor LDAP auth methods, the parameters are:\n```json\n{\n \"start_tls\": false,\n \"insecure_tls\": false,\n \"discover_dn\": false,\n \"anon_group_search\": false,\n \"upn_domain\": \"upn_domain\",\n \"urls\": [],\n \"user_dn\": \"user_dn\",\n \"user_attr\": \"user_attr\",\n \"user_filter\": \"user_filter\",\n \"enable_groups\": false,\n \"group_dn\": \"group_dn\",\n \"group_attr\": \"group_attr\",\n \"group_filter\": \"group_filter\",\n \"certificates\": [],\n \"client_certificate\": \"client_certificate\",\n \"client_certificate_key\": \"client_certificate_key\",\n \"bind_dn\": \"bind_dn\",\n \"bind_password\": \"bind_password\",\n \"use_token_groups\": false,\n \"account_attribute_maps\": [],\n \"maximum_page_size\": 1000,\n \"dereference_aliases\": \"never\"\n}\n```\n" }, "is_primary": { "type": "boolean", @@ -11215,11 +11215,11 @@ "properties": { "@type": { "type": "string", - "description": "A URL/resource name that uniquely identifies the type of the serialized\nprotocol buffer message. This string must contain at least\none \"/\" character. The last segment of the URL's path must represent\nthe fully qualified name of the type (as in\n`path/google.protobuf.Duration`). The name should be in a canonical form\n(e.g., leading \".\" is not accepted).\n\nIn practice, teams usually precompile into the binary all types that they\nexpect it to use in the context of Any. However, for URLs which use the\nscheme `http`, `https`, or no scheme, one can optionally set up a type\nserver that maps type URLs to message definitions as follows:\n\n* If no scheme is provided, `https` is assumed.\n* An HTTP GET on the URL must yield a [google.protobuf.Type][]\n value in binary format, or produce an error.\n* Applications are allowed to cache lookup results based on the\n URL, or have them precompiled into a binary to avoid any\n lookup. Therefore, binary compatibility needs to be preserved\n on changes to types. (Use versioned type names to manage\n breaking changes.)\n\nNote: this functionality is not currently available in the official\nprotobuf release, and it is not used for type URLs beginning with\ntype.googleapis.com.\n\nSchemes other than `http`, `https` (or the empty scheme) might be\nused with implementation specific semantics." + "description": "A URL/resource name that uniquely identifies the type of the serialized\nprotocol buffer message. This string must contain at least\none \"/\" character. The last segment of the URL's path must represent\nthe fully qualified name of the type (as in\n`path/google.protobuf.Duration`). The name should be in a canonical form\n(e.g., leading \".\" is not accepted).\n\nIn practice, teams usually precompile into the binary all types that they\nexpect it to use in the context of Any. However, for URLs which use the\nscheme `http`, `https`, or no scheme, one can optionally set up a type\nserver that maps type URLs to message definitions as follows:\n\n* If no scheme is provided, `https` is assumed.\n* An HTTP GET on the URL must yield a [google.protobuf.Type][]\n value in binary format, or produce an error.\n* Applications are allowed to cache lookup results based on the\n URL, or have them precompiled into a binary to avoid any\n lookup. Therefore, binary compatibility needs to be preserved\n on changes to types. (Use versioned type names to manage\n breaking changes.)\n\nNote: this functionality is not currently available in the official\nprotobuf release, and it is not used for type URLs beginning with\ntype.googleapis.com. As of May 2023, there are no widely used type server\nimplementations and no plans to implement one.\n\nSchemes other than `http`, `https` (or the empty scheme) might be\nused with implementation specific semantics." } }, "additionalProperties": {}, - "description": "`Any` contains an arbitrary serialized protocol buffer message along with a\nURL that describes the type of the serialized message.\n\nProtobuf library provides support to pack/unpack Any values in the form\nof utility functions or additional generated methods of the Any type.\n\nExample 1: Pack and unpack a message in C++.\n\n Foo foo = ...;\n Any any;\n any.PackFrom(foo);\n ...\n if (any.UnpackTo(\u0026foo)) {\n ...\n }\n\nExample 2: Pack and unpack a message in Java.\n\n Foo foo = ...;\n Any any = Any.pack(foo);\n ...\n if (any.is(Foo.class)) {\n foo = any.unpack(Foo.class);\n }\n // or ...\n if (any.isSameTypeAs(Foo.getDefaultInstance())) {\n foo = any.unpack(Foo.getDefaultInstance());\n }\n\nExample 3: Pack and unpack a message in Python.\n\n foo = Foo(...)\n any = Any()\n any.Pack(foo)\n ...\n if any.Is(Foo.DESCRIPTOR):\n any.Unpack(foo)\n ...\n\nExample 4: Pack and unpack a message in Go\n\n foo := \u0026pb.Foo{...}\n any, err := anypb.New(foo)\n if err != nil {\n ...\n }\n ...\n foo := \u0026pb.Foo{}\n if err := any.UnmarshalTo(foo); err != nil {\n ...\n }\n\nThe pack methods provided by protobuf library will by default use\n'type.googleapis.com/full.type.name' as the type URL and the unpack\nmethods only use the fully qualified type name after the last '/'\nin the type URL, for example \"foo.bar.com/x/y.z\" will yield type\nname \"y.z\".\n\nJSON\n\nThe JSON representation of an `Any` value uses the regular\nrepresentation of the deserialized, embedded message, with an\nadditional field `@type` which contains the type URL. Example:\n\n package google.profile;\n message Person {\n string first_name = 1;\n string last_name = 2;\n }\n\n {\n \"@type\": \"type.googleapis.com/google.profile.Person\",\n \"firstName\": \u003cstring\u003e,\n \"lastName\": \u003cstring\u003e\n }\n\nIf the embedded message type is well-known and has a custom JSON\nrepresentation, that representation will be embedded adding a field\n`value` which holds the custom JSON in addition to the `@type`\nfield. Example (for message [google.protobuf.Duration][]):\n\n {\n \"@type\": \"type.googleapis.com/google.protobuf.Duration\",\n \"value\": \"1.212s\"\n }" + "description": "`Any` contains an arbitrary serialized protocol buffer message along with a\nURL that describes the type of the serialized message.\n\nProtobuf library provides support to pack/unpack Any values in the form\nof utility functions or additional generated methods of the Any type.\n\nExample 1: Pack and unpack a message in C++.\n\n Foo foo = ...;\n Any any;\n any.PackFrom(foo);\n ...\n if (any.UnpackTo(\u0026foo)) {\n ...\n }\n\nExample 2: Pack and unpack a message in Java.\n\n Foo foo = ...;\n Any any = Any.pack(foo);\n ...\n if (any.is(Foo.class)) {\n foo = any.unpack(Foo.class);\n }\n // or ...\n if (any.isSameTypeAs(Foo.getDefaultInstance())) {\n foo = any.unpack(Foo.getDefaultInstance());\n }\n\n Example 3: Pack and unpack a message in Python.\n\n foo = Foo(...)\n any = Any()\n any.Pack(foo)\n ...\n if any.Is(Foo.DESCRIPTOR):\n any.Unpack(foo)\n ...\n\n Example 4: Pack and unpack a message in Go\n\n foo := \u0026pb.Foo{...}\n any, err := anypb.New(foo)\n if err != nil {\n ...\n }\n ...\n foo := \u0026pb.Foo{}\n if err := any.UnmarshalTo(foo); err != nil {\n ...\n }\n\nThe pack methods provided by protobuf library will by default use\n'type.googleapis.com/full.type.name' as the type URL and the unpack\nmethods only use the fully qualified type name after the last '/'\nin the type URL, for example \"foo.bar.com/x/y.z\" will yield type\nname \"y.z\".\n\nJSON\n====\nThe JSON representation of an `Any` value uses the regular\nrepresentation of the deserialized, embedded message, with an\nadditional field `@type` which contains the type URL. Example:\n\n package google.profile;\n message Person {\n string first_name = 1;\n string last_name = 2;\n }\n\n {\n \"@type\": \"type.googleapis.com/google.profile.Person\",\n \"firstName\": \u003cstring\u003e,\n \"lastName\": \u003cstring\u003e\n }\n\nIf the embedded message type is well-known and has a custom JSON\nrepresentation, that representation will be embedded adding a field\n`value` which holds the custom JSON in addition to the `@type`\nfield. Example (for message [google.protobuf.Duration][]):\n\n {\n \"@type\": \"type.googleapis.com/google.protobuf.Duration\",\n \"value\": \"1.212s\"\n }" }, "google.protobuf.NullValue": { "type": "string", @@ -11227,7 +11227,7 @@ "NULL_VALUE" ], "default": "NULL_VALUE", - "description": "`NullValue` is a singleton enumeration to represent the null value for the\n`Value` type union.\n\n The JSON representation for `NullValue` is JSON `null`.\n\n - NULL_VALUE: Null value." + "description": "`NullValue` is a singleton enumeration to represent the null value for the\n`Value` type union.\n\nThe JSON representation for `NullValue` is JSON `null`.\n\n - NULL_VALUE: Null value." } }, "securityDefinitions": { diff --git a/internal/gen/testing/event/testing.swagger.json b/internal/gen/testing/event/testing.swagger.json index 199853a3a3..e647ddae5c 100644 --- a/internal/gen/testing/event/testing.swagger.json +++ b/internal/gen/testing/event/testing.swagger.json @@ -58,7 +58,7 @@ "NULL_VALUE" ], "default": "NULL_VALUE", - "description": "`NullValue` is a singleton enumeration to represent the null value for the\n`Value` type union.\n\n The JSON representation for `NullValue` is JSON `null`.\n\n - NULL_VALUE: Null value." + "description": "`NullValue` is a singleton enumeration to represent the null value for the\n`Value` type union.\n\nThe JSON representation for `NullValue` is JSON `null`.\n\n - NULL_VALUE: Null value." }, "testing.event.v1.TestAuthMethodService.TestAuthenticateBody": { "type": "object", diff --git a/internal/proto/controller/api/resources/authmethods/v1/auth_method.proto b/internal/proto/controller/api/resources/authmethods/v1/auth_method.proto index d44b614384..f97bab39ab 100644 --- a/internal/proto/controller/api/resources/authmethods/v1/auth_method.proto +++ b/internal/proto/controller/api/resources/authmethods/v1/auth_method.proto @@ -98,7 +98,9 @@ message AuthMethod { " \"claims_scopes\": [],\n" " \"account_claim_maps\": [],\n" " \"disable_discovered_config_validation\": false,\n" - " \"prompts\": []\n" + " \"prompts\": [],\n" + " \"google_workspace_service_account_json\": \"google_workspace_service_account_json\",\n" + " \"google_workspace_admin_email\": \"google_workspace_admin_email\"\n" "}\n" "```\n" "For LDAP auth methods, the parameters are:\n" @@ -360,6 +362,36 @@ message OidcAuthMethodAttributes { that: "Prompts" } ]; // @gotags: `class:"public"` + + // The JSON key of a Google service account that has been granted + // domain-wide delegation with the scope + // https://www.googleapis.com/auth/admin.directory.group.readonly. + // When set together with google_workspace_admin_email, Boundary calls + // the Google Admin SDK Directory API after token exchange to fetch the + // authenticating user's group memberships and inject them into the + // userinfo claims as "groups". Write-only: the value is encrypted at + // rest and never returned by the API. + google.protobuf.StringValue google_workspace_service_account_json = 150 [ + json_name = "google_workspace_service_account_json", + (custom_options.v1.generate_sdk_option) = true, + (custom_options.v1.mask_mapping) = { + this: "attributes.google_workspace_service_account_json" + that: "GoogleWorkspaceServiceAccountJson" + }, + (google.api.field_behavior) = INPUT_ONLY + ]; // @gotags: `class:"secret"` + + // The email address of a Google Workspace admin that Boundary + // impersonates (via domain-wide delegation) when calling the Directory + // API. Must be set together with google_workspace_service_account_json. + google.protobuf.StringValue google_workspace_admin_email = 160 [ + json_name = "google_workspace_admin_email", + (custom_options.v1.generate_sdk_option) = true, + (custom_options.v1.mask_mapping) = { + this: "attributes.google_workspace_admin_email" + that: "GoogleWorkspaceAdminEmail" + } + ]; // @gotags: `class:"public"` } // The structure of the OIDC authenticate start response, in the JSON object diff --git a/internal/proto/controller/storage/auth/oidc/store/v1/oidc.proto b/internal/proto/controller/storage/auth/oidc/store/v1/oidc.proto index 3c3b9ec77d..59f91aed47 100644 --- a/internal/proto/controller/storage/auth/oidc/store/v1/oidc.proto +++ b/internal/proto/controller/storage/auth/oidc/store/v1/oidc.proto @@ -174,6 +174,29 @@ message AuthMethod { this: "Prompts" that: "attributes.prompts" }]; + + // ct_google_workspace_service_account_json is the encrypted Google service + // account JSON key used to call the Admin SDK Directory API. Stored as the + // column google_workspace_service_account_json in the database. + // @inject_tag: `gorm:"column:google_workspace_service_account_json;default:null" wrapping:"ct,google_workspace_service_account_json"` + bytes ct_google_workspace_service_account_json = 230; + + // google_workspace_service_account_json is the plaintext service account + // JSON key. Not stored in the database. + // @inject_tag: `gorm:"-" wrapping:"pt,google_workspace_service_account_json"` + string google_workspace_service_account_json = 240 [(custom_options.v1.mask_mapping) = { + this: "GoogleWorkspaceServiceAccountJson" + that: "attributes.google_workspace_service_account_json" + }]; + + // google_workspace_admin_email is a Google Workspace admin email address + // that Boundary impersonates (via domain-wide delegation) when calling the + // Directory API to list a user's group memberships. + // @inject_tag: `gorm:"default:null"` + string google_workspace_admin_email = 250 [(custom_options.v1.mask_mapping) = { + this: "GoogleWorkspaceAdminEmail" + that: "attributes.google_workspace_admin_email" + }]; } // Account represents an OIDC account diff --git a/sdk/pbs/controller/api/resources/authmethods/auth_method.pb.go b/sdk/pbs/controller/api/resources/authmethods/auth_method.pb.go index 53861f0d66..fd38879af1 100644 --- a/sdk/pbs/controller/api/resources/authmethods/auth_method.pb.go +++ b/sdk/pbs/controller/api/resources/authmethods/auth_method.pb.go @@ -376,9 +376,22 @@ type OidcAuthMethodAttributes struct { // a result of the update request. DryRun bool `protobuf:"varint,130,opt,name=dry_run,proto3" json:"dry_run,omitempty" class:"public"` // @gotags: `class:"public"` // The prompts allowed for the auth method. - Prompts []string `protobuf:"bytes,140,rep,name=prompts,proto3" json:"prompts,omitempty" class:"public"` // @gotags: `class:"public"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Prompts []string `protobuf:"bytes,140,rep,name=prompts,proto3" json:"prompts,omitempty" class:"public"` // @gotags: `class:"public"` + // The JSON key of a Google service account that has been granted + // domain-wide delegation with the scope + // https://www.googleapis.com/auth/admin.directory.group.readonly. + // When set together with google_workspace_admin_email, Boundary calls + // the Google Admin SDK Directory API after token exchange to fetch the + // authenticating user's group memberships and inject them into the + // userinfo claims as "groups". Write-only: the value is encrypted at + // rest and never returned by the API. + GoogleWorkspaceServiceAccountJson *wrapperspb.StringValue `protobuf:"bytes,150,opt,name=google_workspace_service_account_json,proto3" json:"google_workspace_service_account_json,omitempty" class:"secret"` // @gotags: `class:"secret"` + // The email address of a Google Workspace admin that Boundary + // impersonates (via domain-wide delegation) when calling the Directory + // API. Must be set together with google_workspace_service_account_json. + GoogleWorkspaceAdminEmail *wrapperspb.StringValue `protobuf:"bytes,160,opt,name=google_workspace_admin_email,proto3" json:"google_workspace_admin_email,omitempty" class:"public"` // @gotags: `class:"public"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OidcAuthMethodAttributes) Reset() { @@ -523,6 +536,20 @@ func (x *OidcAuthMethodAttributes) GetPrompts() []string { return nil } +func (x *OidcAuthMethodAttributes) GetGoogleWorkspaceServiceAccountJson() *wrapperspb.StringValue { + if x != nil { + return x.GoogleWorkspaceServiceAccountJson + } + return nil +} + +func (x *OidcAuthMethodAttributes) GetGoogleWorkspaceAdminEmail() *wrapperspb.StringValue { + if x != nil { + return x.GoogleWorkspaceAdminEmail + } + return nil +} + // The structure of the OIDC authenticate start response, in the JSON object type OidcAuthMethodAuthenticateStartResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1115,7 +1142,7 @@ var File_controller_api_resources_authmethods_v1_auth_method_proto protoreflect. const file_controller_api_resources_authmethods_v1_auth_method_proto_rawDesc = "" + "\n" + - "9controller/api/resources/authmethods/v1/auth_method.proto\x12'controller.api.resources.authmethods.v1\x1a.controller/api/resources/scopes/v1/scope.proto\x1a*controller/custom_options/v1/options.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/api/visibility.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\x89\x17\n" + + "9controller/api/resources/authmethods/v1/auth_method.proto\x12'controller.api.resources.authmethods.v1\x1a.controller/api/resources/scopes/v1/scope.proto\x1a*controller/custom_options/v1/options.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x1bgoogle/api/visibility.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\x1a.protoc-gen-openapiv2/options/annotations.proto\"\x9f\x18\n" + "\n" + "AuthMethod\x12\x14\n" + "\x02id\x18\n" + @@ -1129,9 +1156,9 @@ const file_controller_api_resources_authmethods_v1_auth_method_proto_rawDesc = " "\fcreated_time\x18< \x01(\v2\x1a.google.protobuf.TimestampB\x04\xe2A\x01\x03R\fcreated_time\x12D\n" + "\fupdated_time\x18F \x01(\v2\x1a.google.protobuf.TimestampB\x04\xe2A\x01\x03R\fupdated_time\x12\x18\n" + "\aversion\x18P \x01(\rR\aversion\x12\x12\n" + - "\x04type\x18Z \x01(\tR\x04type\x12\xa4\f\n" + + "\x04type\x18Z \x01(\tR\x04type\x12\xba\r\n" + "\n" + - "attributes\x18d \x01(\v2\x17.google.protobuf.StructB\xe8\v\x92A\xd5\v2\x98\vThe attributes that are applicable for the specific auth method type. The schema of this field depends on the type of the auth method that you create want to create.\n" + + "attributes\x18d \x01(\v2\x17.google.protobuf.StructB\xfe\f\x92A\xeb\f2\xae\fThe attributes that are applicable for the specific auth method type. The schema of this field depends on the type of the auth method that you create want to create.\n" + "For password auth methods, the parameters are:\n" + "```json\n" + "{\n" + @@ -1153,7 +1180,9 @@ const file_controller_api_resources_authmethods_v1_auth_method_proto_rawDesc = " " \"claims_scopes\": [],\n" + " \"account_claim_maps\": [],\n" + " \"disable_discovered_config_validation\": false,\n" + - " \"prompts\": []\n" + + " \"prompts\": [],\n" + + " \"google_workspace_service_account_json\": \"google_workspace_service_account_json\",\n" + + " \"google_workspace_admin_email\": \"google_workspace_admin_email\"\n" + "}\n" + "```\n" + "For LDAP auth methods, the parameters are:\n" + @@ -1205,8 +1234,7 @@ const file_controller_api_resources_authmethods_v1_auth_method_proto_rawDesc = " " \x01(\rB>\xa0\xda)\x01\xc2\xdd)6\n" + " attributes.min_login_name_length\x12\x12MinLoginNameLengthR\x15min_login_name_length\x12m\n" + "\x13min_password_length\x18\x14 \x01(\rB;\xa0\xda)\x01\xc2\xdd)3\n" + - "\x1eattributes.min_password_length\x12\x11MinPasswordLengthR\x13min_password_length\"\xbe\n" + - "\n" + + "\x1eattributes.min_password_length\x12\x11MinPasswordLengthR\x13min_password_length\"\xc9\r\n" + "\x18OidcAuthMethodAttributes\x12\x1a\n" + "\x05state\x18\n" + " \x01(\tB\x04\xe2A\x01\x03R\x05state\x12Y\n" + @@ -1235,7 +1263,11 @@ const file_controller_api_resources_authmethods_v1_auth_method_proto_rawDesc = " "$disable_discovered_config_validation\x18x \x01(\bB\x04\xa0\xda)\x01R$disable_discovered_config_validation\x12\x1f\n" + "\adry_run\x18\x82\x01 \x01(\bB\x04\xa0\xda)\x01R\adry_run\x12@\n" + "\aprompts\x18\x8c\x01 \x03(\tB%\xa0\xda)\x01\xc2\xdd)\x1d\n" + - "\x12attributes.prompts\x12\aPromptsR\aprompts\"a\n" + + "\x12attributes.prompts\x12\aPromptsR\aprompts\x12\xd6\x01\n" + + "%google_workspace_service_account_json\x18\x96\x01 \x01(\v2\x1c.google.protobuf.StringValueBa\xe2A\x01\x04\xa0\xda)\x01\xc2\xdd)U\n" + + "0attributes.google_workspace_service_account_json\x12!GoogleWorkspaceServiceAccountJsonR%google_workspace_service_account_json\x12\xaf\x01\n" + + "\x1cgoogle_workspace_admin_email\x18\xa0\x01 \x01(\v2\x1c.google.protobuf.StringValueBL\xa0\xda)\x01\xc2\xdd)D\n" + + "'attributes.google_workspace_admin_email\x12\x19GoogleWorkspaceAdminEmailR\x1cgoogle_workspace_admin_email\"a\n" + "'OidcAuthMethodAuthenticateStartResponse\x12\x1a\n" + "\bauth_url\x18\n" + " \x01(\tR\bauth_url\x12\x1a\n" + @@ -1360,24 +1392,26 @@ var file_controller_api_resources_authmethods_v1_auth_method_proto_depIdxs = []i 11, // 12: controller.api.resources.authmethods.v1.OidcAuthMethodAttributes.client_secret:type_name -> google.protobuf.StringValue 14, // 13: controller.api.resources.authmethods.v1.OidcAuthMethodAttributes.max_age:type_name -> google.protobuf.UInt32Value 11, // 14: controller.api.resources.authmethods.v1.OidcAuthMethodAttributes.api_url_prefix:type_name -> google.protobuf.StringValue - 11, // 15: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.upn_domain:type_name -> google.protobuf.StringValue - 11, // 16: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.user_dn:type_name -> google.protobuf.StringValue - 11, // 17: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.user_attr:type_name -> google.protobuf.StringValue - 11, // 18: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.user_filter:type_name -> google.protobuf.StringValue - 11, // 19: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.group_dn:type_name -> google.protobuf.StringValue - 11, // 20: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.group_attr:type_name -> google.protobuf.StringValue - 11, // 21: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.group_filter:type_name -> google.protobuf.StringValue - 11, // 22: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.client_certificate:type_name -> google.protobuf.StringValue - 11, // 23: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.client_certificate_key:type_name -> google.protobuf.StringValue - 11, // 24: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.bind_dn:type_name -> google.protobuf.StringValue - 11, // 25: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.bind_password:type_name -> google.protobuf.StringValue - 11, // 26: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.dereference_aliases:type_name -> google.protobuf.StringValue - 15, // 27: controller.api.resources.authmethods.v1.AuthMethod.AuthorizedCollectionActionsEntry.value:type_name -> google.protobuf.ListValue - 28, // [28:28] is the sub-list for method output_type - 28, // [28:28] is the sub-list for method input_type - 28, // [28:28] is the sub-list for extension type_name - 28, // [28:28] is the sub-list for extension extendee - 0, // [0:28] is the sub-list for field type_name + 11, // 15: controller.api.resources.authmethods.v1.OidcAuthMethodAttributes.google_workspace_service_account_json:type_name -> google.protobuf.StringValue + 11, // 16: controller.api.resources.authmethods.v1.OidcAuthMethodAttributes.google_workspace_admin_email:type_name -> google.protobuf.StringValue + 11, // 17: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.upn_domain:type_name -> google.protobuf.StringValue + 11, // 18: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.user_dn:type_name -> google.protobuf.StringValue + 11, // 19: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.user_attr:type_name -> google.protobuf.StringValue + 11, // 20: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.user_filter:type_name -> google.protobuf.StringValue + 11, // 21: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.group_dn:type_name -> google.protobuf.StringValue + 11, // 22: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.group_attr:type_name -> google.protobuf.StringValue + 11, // 23: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.group_filter:type_name -> google.protobuf.StringValue + 11, // 24: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.client_certificate:type_name -> google.protobuf.StringValue + 11, // 25: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.client_certificate_key:type_name -> google.protobuf.StringValue + 11, // 26: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.bind_dn:type_name -> google.protobuf.StringValue + 11, // 27: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.bind_password:type_name -> google.protobuf.StringValue + 11, // 28: controller.api.resources.authmethods.v1.LdapAuthMethodAttributes.dereference_aliases:type_name -> google.protobuf.StringValue + 15, // 29: controller.api.resources.authmethods.v1.AuthMethod.AuthorizedCollectionActionsEntry.value:type_name -> google.protobuf.ListValue + 30, // [30:30] is the sub-list for method output_type + 30, // [30:30] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name } func init() { file_controller_api_resources_authmethods_v1_auth_method_proto_init() }