From c8100e5c8ae15215135a1cdecbb6ac8f35f6c60a Mon Sep 17 00:00:00 2001 From: Frode Sundby Date: Thu, 12 Feb 2026 13:36:05 +0100 Subject: [PATCH] feat(postgres): add grant access for in-cluster postgres Add logic to grant temporary access to in-cluster postgres databases via GraphQL mutation. Update secret retrieval to distinguish between CloudSQL and in-cluster postgres. Update genqlient config and generated code for new mutation. --- genqlient.yaml | 1 + internal/naisapi/gql/generated.go | 275 ++++++++++-------------------- internal/postgres/secret.go | 234 +++++++++++++++++++++++-- 3 files changed, 304 insertions(+), 206 deletions(-) diff --git a/genqlient.yaml b/genqlient.yaml index 8477b834..c43d08f3 100644 --- a/genqlient.yaml +++ b/genqlient.yaml @@ -7,6 +7,7 @@ operations: - internal/member/**/*.go - internal/naisapi/**/*.go - internal/app/**/*.go + - internal/postgres/**/*.go bindings: Slug: type: string diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 19b9d829..2e4ea3cd 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -209,124 +209,6 @@ var AllApplicationState = []ApplicationState{ ApplicationStateUnknown, } -// CreateElevationCreateElevationCreateElevationPayload includes the requested fields of the GraphQL type CreateElevationPayload. -// The GraphQL type's documentation follows. -// -// Payload returned when creating an elevation. -type CreateElevationCreateElevationCreateElevationPayload struct { - // The created elevation. - Elevation CreateElevationCreateElevationCreateElevationPayloadElevation `json:"elevation"` -} - -// GetElevation returns CreateElevationCreateElevationCreateElevationPayload.Elevation, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayload) GetElevation() CreateElevationCreateElevationCreateElevationPayloadElevation { - return v.Elevation -} - -// CreateElevationCreateElevationCreateElevationPayloadElevation includes the requested fields of the GraphQL type Elevation. -// The GraphQL type's documentation follows. -// -// An active elevation grants temporary elevated privileges to a specific resource. -type CreateElevationCreateElevationCreateElevationPayloadElevation struct { - // Unique ID of the elevation. - Id string `json:"id"` - // Type of elevation. - Type ElevationType `json:"type"` - // Team that owns the resource. - Team CreateElevationCreateElevationCreateElevationPayloadElevationTeam `json:"team"` - // Name of the resource being elevated to. - ResourceName string `json:"resourceName"` - // Reason provided for the elevation. - Reason string `json:"reason"` -} - -// GetId returns CreateElevationCreateElevationCreateElevationPayloadElevation.Id, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetId() string { return v.Id } - -// GetType returns CreateElevationCreateElevationCreateElevationPayloadElevation.Type, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetType() ElevationType { - return v.Type -} - -// GetTeam returns CreateElevationCreateElevationCreateElevationPayloadElevation.Team, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetTeam() CreateElevationCreateElevationCreateElevationPayloadElevationTeam { - return v.Team -} - -// GetResourceName returns CreateElevationCreateElevationCreateElevationPayloadElevation.ResourceName, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetResourceName() string { - return v.ResourceName -} - -// GetReason returns CreateElevationCreateElevationCreateElevationPayloadElevation.Reason, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetReason() string { - return v.Reason -} - -// CreateElevationCreateElevationCreateElevationPayloadElevationTeam includes the requested fields of the GraphQL type Team. -// The GraphQL type's documentation follows. -// -// The team type represents a team on the [Nais platform](https://nais.io/). -// -// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). -// -// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). -type CreateElevationCreateElevationCreateElevationPayloadElevationTeam struct { - // Unique slug of the team. - Slug string `json:"slug"` -} - -// GetSlug returns CreateElevationCreateElevationCreateElevationPayloadElevationTeam.Slug, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevationTeam) GetSlug() string { - return v.Slug -} - -// Input for creating an elevation. -type CreateElevationInput struct { - // Input for creating an elevation. - Type ElevationType `json:"type"` - // Input for creating an elevation. - Team string `json:"team"` - // Input for creating an elevation. - EnvironmentName string `json:"environmentName"` - // Input for creating an elevation. - ResourceName string `json:"resourceName"` - // Input for creating an elevation. - Reason string `json:"reason"` - // Input for creating an elevation. - DurationMinutes int `json:"durationMinutes"` -} - -// GetType returns CreateElevationInput.Type, and is useful for accessing the field via an interface. -func (v *CreateElevationInput) GetType() ElevationType { return v.Type } - -// GetTeam returns CreateElevationInput.Team, and is useful for accessing the field via an interface. -func (v *CreateElevationInput) GetTeam() string { return v.Team } - -// GetEnvironmentName returns CreateElevationInput.EnvironmentName, and is useful for accessing the field via an interface. -func (v *CreateElevationInput) GetEnvironmentName() string { return v.EnvironmentName } - -// GetResourceName returns CreateElevationInput.ResourceName, and is useful for accessing the field via an interface. -func (v *CreateElevationInput) GetResourceName() string { return v.ResourceName } - -// GetReason returns CreateElevationInput.Reason, and is useful for accessing the field via an interface. -func (v *CreateElevationInput) GetReason() string { return v.Reason } - -// GetDurationMinutes returns CreateElevationInput.DurationMinutes, and is useful for accessing the field via an interface. -func (v *CreateElevationInput) GetDurationMinutes() int { return v.DurationMinutes } - -// CreateElevationResponse is returned by CreateElevation on success. -type CreateElevationResponse struct { - // Create a temporary elevation of privileges for a specific resource. - // The elevation expires automatically after the specified duration. - CreateElevation CreateElevationCreateElevationCreateElevationPayload `json:"createElevation"` -} - -// GetCreateElevation returns CreateElevationResponse.CreateElevation, and is useful for accessing the field via an interface. -func (v *CreateElevationResponse) GetCreateElevation() CreateElevationCreateElevationCreateElevationPayload { - return v.CreateElevation -} - // CreateOpenSearchCreateOpenSearchCreateOpenSearchPayload includes the requested fields of the GraphQL type CreateOpenSearchPayload. type CreateOpenSearchCreateOpenSearchCreateOpenSearchPayload struct { // OpenSearch instance that was created. @@ -441,27 +323,6 @@ func (v *DeleteValkeyResponse) GetDeleteValkey() DeleteValkeyDeleteValkeyDeleteV return v.DeleteValkey } -// Type of elevation that can be requested. -type ElevationType string - -const ( - // Access to read secrets in plain text. - ElevationTypeSecret ElevationType = "SECRET" - // Access to execute commands in an instance. - ElevationTypeInstanceExec ElevationType = "INSTANCE_EXEC" - // Access to port-forward to an instance. - ElevationTypeInstancePortForward ElevationType = "INSTANCE_PORT_FORWARD" - // Access to debug an instance with ephemeral containers. - ElevationTypeInstanceDebug ElevationType = "INSTANCE_DEBUG" -) - -var AllElevationType = []ElevationType{ - ElevationTypeSecret, - ElevationTypeInstanceExec, - ElevationTypeInstancePortForward, - ElevationTypeInstanceDebug, -} - // EnvironmentsEnvironmentsEnvironmentConnection includes the requested fields of the GraphQL type EnvironmentConnection. // The GraphQL type's documentation follows. // @@ -5497,6 +5358,50 @@ func (v *GetValkeyTeamEnvironmentValkeyAccessValkeyAccessConnectionEdgesValkeyAc return v.Slug } +// GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload includes the requested fields of the GraphQL type GrantPostgresAccessPayload. +type GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload struct { + Error string `json:"error"` +} + +// GetError returns GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload.Error, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload) GetError() string { + return v.Error +} + +type GrantPostgresAccessInput struct { + ClusterName string `json:"clusterName"` + TeamSlug string `json:"teamSlug"` + EnvironmentName string `json:"environmentName"` + Grantee string `json:"grantee"` + Duration string `json:"duration"` +} + +// GetClusterName returns GrantPostgresAccessInput.ClusterName, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessInput) GetClusterName() string { return v.ClusterName } + +// GetTeamSlug returns GrantPostgresAccessInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessInput) GetTeamSlug() string { return v.TeamSlug } + +// GetEnvironmentName returns GrantPostgresAccessInput.EnvironmentName, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessInput) GetEnvironmentName() string { return v.EnvironmentName } + +// GetGrantee returns GrantPostgresAccessInput.Grantee, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessInput) GetGrantee() string { return v.Grantee } + +// GetDuration returns GrantPostgresAccessInput.Duration, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessInput) GetDuration() string { return v.Duration } + +// GrantPostgresAccessResponse is returned by GrantPostgresAccess on success. +type GrantPostgresAccessResponse struct { + // Grant access to this postgres cluster + GrantPostgresAccess GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload `json:"grantPostgresAccess"` +} + +// GetGrantPostgresAccess returns GrantPostgresAccessResponse.GrantPostgresAccess, and is useful for accessing the field via an interface. +func (v *GrantPostgresAccessResponse) GetGrantPostgresAccess() GrantPostgresAccessGrantPostgresAccessGrantPostgresAccessPayload { + return v.GrantPostgresAccess +} + // IsAdminMeAuthenticatedUser includes the requested fields of the GraphQL interface AuthenticatedUser. // // IsAdminMeAuthenticatedUser is implemented by the following types: @@ -7673,14 +7578,6 @@ func (v *__ApplicationEnvironmentsInput) GetTeam() string { return v.Team } // GetFilter returns __ApplicationEnvironmentsInput.Filter, and is useful for accessing the field via an interface. func (v *__ApplicationEnvironmentsInput) GetFilter() TeamApplicationsFilter { return v.Filter } -// __CreateElevationInput is used internally by genqlient -type __CreateElevationInput struct { - Input CreateElevationInput `json:"input"` -} - -// GetInput returns __CreateElevationInput.Input, and is useful for accessing the field via an interface. -func (v *__CreateElevationInput) GetInput() CreateElevationInput { return v.Input } - // __CreateOpenSearchInput is used internally by genqlient type __CreateOpenSearchInput struct { Name string `json:"name,omitempty"` @@ -7897,6 +7794,14 @@ func (v *__GetValkeyInput) GetEnvironmentName() string { return v.EnvironmentNam // GetTeamSlug returns __GetValkeyInput.TeamSlug, and is useful for accessing the field via an interface. func (v *__GetValkeyInput) GetTeamSlug() string { return v.TeamSlug } +// __GrantPostgresAccessInput is used internally by genqlient +type __GrantPostgresAccessInput struct { + Input GrantPostgresAccessInput `json:"input"` +} + +// GetInput returns __GrantPostgresAccessInput.Input, and is useful for accessing the field via an interface. +func (v *__GrantPostgresAccessInput) GetInput() GrantPostgresAccessInput { return v.Input } + // __RemoveTeamMemberInput is used internally by genqlient type __RemoveTeamMemberInput struct { Slug string `json:"slug"` @@ -8122,48 +8027,6 @@ func ApplicationEnvironments( return data_, err_ } -// The mutation executed by CreateElevation. -const CreateElevation_Operation = ` -mutation CreateElevation ($input: CreateElevationInput!) { - createElevation(input: $input) { - elevation { - id - type - team { - slug - } - resourceName - reason - } - } -} -` - -func CreateElevation( - ctx_ context.Context, - client_ graphql.Client, - input CreateElevationInput, -) (data_ *CreateElevationResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "CreateElevation", - Query: CreateElevation_Operation, - Variables: &__CreateElevationInput{ - Input: input, - }, - } - - data_ = &CreateElevationResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by CreateOpenSearch. const CreateOpenSearch_Operation = ` mutation CreateOpenSearch ($name: String!, $environmentName: String!, $teamSlug: Slug!, $memory: OpenSearchMemory!, $tier: OpenSearchTier!, $version: OpenSearchMajorVersion!, $storageGB: Int!) { @@ -8988,6 +8851,40 @@ func GetValkey( return data_, err_ } +// The mutation executed by GrantPostgresAccess. +const GrantPostgresAccess_Operation = ` +mutation GrantPostgresAccess ($input: GrantPostgresAccessInput!) { + grantPostgresAccess(input: $input) { + error + } +} +` + +func GrantPostgresAccess( + ctx_ context.Context, + client_ graphql.Client, + input GrantPostgresAccessInput, +) (data_ *GrantPostgresAccessResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GrantPostgresAccess", + Query: GrantPostgresAccess_Operation, + Variables: &__GrantPostgresAccessInput{ + Input: input, + }, + } + + data_ = &GrantPostgresAccessResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by IsAdmin. const IsAdmin_Operation = ` query IsAdmin { diff --git a/internal/postgres/secret.go b/internal/postgres/secret.go index e89680a9..56e6c6ed 100644 --- a/internal/postgres/secret.go +++ b/internal/postgres/secret.go @@ -6,8 +6,13 @@ import ( "strings" "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/naisapi/gql" "github.com/nais/cli/internal/postgres/command/flag" "github.com/nais/naistrix" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/clientcmd" ) // Hardcoded reasons for administrative operations @@ -22,15 +27,18 @@ const ( ReasonVerifyAudit = "Verifying audit configuration via nais CLI" ) +// Default duration for in-cluster postgres access grants +const defaultPostgresAccessDuration = "1h" + // SecretValues holds the secret values retrieved from the API type SecretValues struct { values map[string]string } // GetSecretValues retrieves the values of a database secret via the API. -// This is the preferred method for accessing secret values as it combines -// authorization, logging, and value retrieval in a single operation. -// The access is logged for audit purposes. +// For CloudSQL databases, this retrieves the secret values directly. +// For in-cluster postgres, this grants temporary access to the database. +// In both cases, the access is logged for audit purposes. func GetSecretValues(ctx context.Context, appName string, fl *flag.Postgres, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { if reason == "" { reason = fl.Reason @@ -39,14 +47,17 @@ func GetSecretValues(ctx context.Context, appName string, fl *flag.Postgres, rea } } + // Use --team flag, fall back to --namespace if not set team := fl.Team if team == "" { team = string(fl.Namespace) if team == "" { - return nil, fmt.Errorf("team is required") + return nil, fmt.Errorf("team is required (use --team or --namespace flag)") } } + out.Printf("Using team %q\n", team) + environment := string(fl.Environment) if environment == "" { environment = string(fl.Context) @@ -55,10 +66,87 @@ func GetSecretValues(ctx context.Context, appName string, fl *flag.Postgres, rea } } + // Check if this is a CloudSQL or in-cluster postgres database + isCloudSQL, err := isCloudSQLDatabase(ctx, appName, fl) + if err != nil { + return nil, fmt.Errorf("checking database type: %w", err) + } + + if isCloudSQL { + return getCloudSQLSecretValues(ctx, appName, team, environment, reason, out) + } + + return grantInClusterPostgresAccess(ctx, appName, fl, team, environment, reason, out) +} + +// GetSecretValuesWithUserReason retrieves secret values with a user-provided reason. +// This should be used for interactive operations like proxy and psql where the user +// should provide justification for accessing the database. +func GetSecretValuesWithUserReason(ctx context.Context, appName string, fl *flag.Postgres, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { + if reason == "" { + reason = fl.Reason + if reason == "" { + return nil, fmt.Errorf("reason is required for accessing database secrets (use --reason flag)") + } + } + + if len(reason) < 10 { + return nil, fmt.Errorf("reason must be at least 10 characters") + } + + return GetSecretValues(ctx, appName, fl, reason, out) +} + +// isCloudSQLDatabase checks if the given app uses CloudSQL or in-cluster postgres +func isCloudSQLDatabase(ctx context.Context, appName string, fl *flag.Postgres) (bool, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + // Use Context if set, otherwise fall back to Environment (they often map to the same thing) + kubeContext := string(fl.Context) + if kubeContext == "" { + kubeContext = string(fl.Environment) + } + configOverrides := &clientcmd.ConfigOverrides{ + CurrentContext: kubeContext, + } + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + // Use namespace for Kubernetes operations, fall back to team + ns := string(fl.Namespace) + if ns == "" { + ns = fl.Team + } + + config, err := kubeConfig.ClientConfig() + if err != nil { + return false, fmt.Errorf("unable to get kubeconfig: %w", err) + } + + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return false, fmt.Errorf("unable to create dynamic client: %w", err) + } + + // Check for CloudSQL SQLInstance resources + sqlInstances, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "sql.cnrm.cloud.google.com", + Version: "v1beta1", + Resource: "sqlinstances", + }).Namespace(ns).List(ctx, v1.ListOptions{ + LabelSelector: "app=" + appName, + }) + if err != nil { + return false, fmt.Errorf("error looking for sqlinstance %q in %q: %w", appName, ns, err) + } + + return len(sqlInstances.Items) >= 1, nil +} + +// getCloudSQLSecretValues retrieves secret values for CloudSQL databases +func getCloudSQLSecretValues(ctx context.Context, appName, team, environment, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { // The secret name follows the pattern "google-sql-" secretName := "google-sql-" + appName - out.Debugf("Requesting access to secret %q for database connection...\n", secretName) + out.Debugf("Requesting access to CloudSQL secret %q...\n", secretName) values, err := naisapi.ViewSecretValues(ctx, team, environment, secretName, reason) if err != nil { @@ -66,7 +154,7 @@ func GetSecretValues(ctx context.Context, appName string, fl *flag.Postgres, rea if strings.Contains(err.Error(), "not authorized") || strings.Contains(err.Error(), "Not authorized") { return nil, fmt.Errorf("you are not authorized to access this database. Make sure you are a member of team %q", team) } - return nil, fmt.Errorf("viewing secret values: %w", err) + return nil, err } out.Debugf("✅ Access granted.\n") @@ -82,20 +170,132 @@ func GetSecretValues(ctx context.Context, appName string, fl *flag.Postgres, rea return result, nil } -// GetSecretValuesWithUserReason retrieves secret values with a user-provided reason. -// This should be used for interactive operations like proxy and psql where the user -// should provide justification for accessing the database. -func GetSecretValuesWithUserReason(ctx context.Context, appName string, fl *flag.Postgres, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { - if reason == "" { - reason = fl.Reason - if reason == "" { - return nil, fmt.Errorf("reason is required for accessing database secrets (use --reason flag)") +// getPostgresClusterName retrieves the postgres cluster name for an app +func getPostgresClusterName(ctx context.Context, appName string, fl *flag.Postgres) (string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + // Use Context if set, otherwise fall back to Environment (they often map to the same thing) + kubeContext := string(fl.Context) + if kubeContext == "" { + kubeContext = string(fl.Environment) + } + configOverrides := &clientcmd.ConfigOverrides{ + CurrentContext: kubeContext, + } + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + // Use namespace for Kubernetes operations, fall back to team + ns := string(fl.Namespace) + if ns == "" { + ns = fl.Team + } + + config, err := kubeConfig.ClientConfig() + if err != nil { + return "", fmt.Errorf("unable to get kubeconfig: %w", err) + } + + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return "", fmt.Errorf("unable to create dynamic client: %w", err) + } + + // First try to get the cluster name from the Application spec + unstructuredApp, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "nais.io", + Version: "v1alpha1", + Resource: "applications", + }).Namespace(ns).Get(ctx, appName, v1.GetOptions{}) + if err == nil { + spec, ok := unstructuredApp.Object["spec"].(map[string]interface{}) + if ok { + postgres, ok := spec["postgres"].(map[string]interface{}) + if ok { + clusterName, ok := postgres["clusterName"].(string) + if ok && clusterName != "" { + return clusterName, nil + } + } } } - if len(reason) < 10 { - return nil, fmt.Errorf("reason must be at least 10 characters") + // If no Application found or no clusterName in spec, check if there's a Postgres resource with this name + _, err = dynamicClient.Resource(schema.GroupVersionResource{ + Group: "data.nais.io", + Version: "v1", + Resource: "postgres", + }).Namespace(ns).Get(ctx, appName, v1.GetOptions{}) + if err == nil { + // The appName is actually a postgres cluster name + return appName, nil } - return GetSecretValues(ctx, appName, fl, reason, out) + return "", fmt.Errorf("unable to find postgres cluster for %q in %q", appName, ns) +} + +// grantPostgresAccess grants temporary access to an in-cluster postgres database. +// This creates a time-limited grant for the user and logs the access for auditing purposes. +func grantPostgresAccess(ctx context.Context, clusterName, teamSlug, environmentName, grantee, duration string) error { + _ = `# @genqlient +mutation GrantPostgresAccess($input: GrantPostgresAccessInput!) { + grantPostgresAccess(input: $input) { + error + } +} +` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return fmt.Errorf("creating GraphQL client: %w", err) + } + + resp, err := gql.GrantPostgresAccess(ctx, client, gql.GrantPostgresAccessInput{ + ClusterName: clusterName, + TeamSlug: teamSlug, + EnvironmentName: environmentName, + Grantee: grantee, + Duration: duration, + }) + if err != nil { + return fmt.Errorf("granting postgres access: %w", err) + } + + if resp.GrantPostgresAccess.Error != "" { + return fmt.Errorf("granting postgres access: %s", resp.GrantPostgresAccess.Error) + } + + return nil +} + +// grantInClusterPostgresAccess grants access to in-cluster postgres databases +func grantInClusterPostgresAccess(ctx context.Context, appName string, fl *flag.Postgres, team, environment, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { + // Get the postgres cluster name + clusterName, err := getPostgresClusterName(ctx, appName, fl) + if err != nil { + return nil, err + } + + // Get the authenticated user's email + user, err := naisapi.GetAuthenticatedUser(ctx) + if err != nil { + return nil, fmt.Errorf("getting authenticated user: %w", err) + } + grantee := user.Email() + + out.Debugf("Requesting access to in-cluster postgres %q for user %q...\n", clusterName, grantee) + + // Grant access via the API (this logs the access for audit purposes) + err = grantPostgresAccess(ctx, clusterName, team, environment, grantee, defaultPostgresAccessDuration) + if err != nil { + // Check if the error indicates the user is not authorized + if strings.Contains(err.Error(), "not authorized") || strings.Contains(err.Error(), "Not authorized") { + return nil, fmt.Errorf("you are not authorized to access this database. Make sure you are a member of team %q", team) + } + return nil, fmt.Errorf("granting postgres access: %w", err) + } + + out.Debugf("✅ Access granted for %s.\n", defaultPostgresAccessDuration) + + // For in-cluster postgres, we don't return secret values as authentication + // happens via OAuth tokens, not via secrets + return &SecretValues{values: make(map[string]string)}, nil }