From 382e3b608f72f687ac645e8e507df8dcfbd88a5c Mon Sep 17 00:00:00 2001 From: frodesundby Date: Thu, 29 Jan 2026 09:14:46 +0100 Subject: [PATCH] refactor: fjern elevation-konseptet fra CLI Co-authored-by: Johnny Horvi --- internal/naisapi/auth/localhost.go | 6 ++ internal/naisapi/elevation.go | 76 -------------- internal/naisapi/gql/generated.go | 156 +++++++++++++++++++++-------- internal/naisapi/secret.go | 54 ++++++++++ internal/postgres/access.go | 8 +- internal/postgres/audit.go | 8 +- internal/postgres/elevation.go | 98 ------------------ internal/postgres/iam.go | 12 +-- internal/postgres/password.go | 4 +- internal/postgres/proxy.go | 4 +- internal/postgres/psql.go | 4 +- internal/postgres/secret.go | 143 ++++++++++++++++++++++++++ schema.graphql | 111 +++++++++++++++++++- 13 files changed, 445 insertions(+), 239 deletions(-) delete mode 100644 internal/naisapi/elevation.go create mode 100644 internal/naisapi/secret.go delete mode 100644 internal/postgres/elevation.go create mode 100644 internal/postgres/secret.go diff --git a/internal/naisapi/auth/localhost.go b/internal/naisapi/auth/localhost.go index df03ab64..92478d40 100644 --- a/internal/naisapi/auth/localhost.go +++ b/internal/naisapi/auth/localhost.go @@ -2,6 +2,7 @@ package auth import ( "context" + "fmt" "net/http" "os" @@ -30,6 +31,11 @@ func Localhost() (*LocalhostUser, bool) { }, true } +// APIURL overrides the parent method to use HTTP instead of HTTPS for local development +func (l *LocalhostUser) APIURL() string { + return fmt.Sprintf("http://%s/graphql", l.ConsoleHost()) +} + func (l *LocalhostUser) HTTPClient(_ context.Context) *http.Client { return &http.Client{ Transport: l.RoundTripper(http.DefaultTransport), diff --git a/internal/naisapi/elevation.go b/internal/naisapi/elevation.go deleted file mode 100644 index fc6434ef..00000000 --- a/internal/naisapi/elevation.go +++ /dev/null @@ -1,76 +0,0 @@ -package naisapi - -import ( - "context" - "fmt" - - "github.com/nais/cli/internal/naisapi/gql" -) - -// ElevationType represents the type of elevation to request -type ElevationType = gql.ElevationType - -const ( - ElevationTypeSecret ElevationType = gql.ElevationTypeSecret -) - -// Elevation represents an active elevation -type Elevation struct { - ID string - Type ElevationType - TeamSlug string - EnvironmentName string - ResourceName string - Reason string -} - -// CreateElevation creates a temporary elevation of privileges for a specific resource. -// This is required before accessing sensitive resources like secrets. -func CreateElevation(ctx context.Context, team, environmentName, resourceName, reason string, durationMinutes int) (*Elevation, error) { - if durationMinutes <= 0 { - durationMinutes = 5 - } - - _ = `# @genqlient - mutation CreateElevation($input: CreateElevationInput!) { - createElevation(input: $input) { - elevation { - id - type - team { - slug - } - resourceName - reason - } - } - } - ` - - client, err := GraphqlClient(ctx) - if err != nil { - return nil, fmt.Errorf("creating GraphQL client: %w", err) - } - - resp, err := gql.CreateElevation(ctx, client, gql.CreateElevationInput{ - Type: gql.ElevationTypeSecret, - Team: team, - EnvironmentName: environmentName, - ResourceName: resourceName, - Reason: reason, - DurationMinutes: durationMinutes, - }) - if err != nil { - return nil, fmt.Errorf("creating elevation: %w", err) - } - - elev := resp.CreateElevation.Elevation - return &Elevation{ - ID: elev.Id, - Type: elev.Type, - TeamSlug: elev.Team.Slug, - EnvironmentName: environmentName, - ResourceName: elev.ResourceName, - Reason: elev.Reason, - }, nil -} diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index d41d0dcf..19b9d829 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -234,8 +234,6 @@ type CreateElevationCreateElevationCreateElevationPayloadElevation struct { Type ElevationType `json:"type"` // Team that owns the resource. Team CreateElevationCreateElevationCreateElevationPayloadElevationTeam `json:"team"` - // Environment where the resource is located. - TeamEnvironment CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironment `json:"teamEnvironment"` // Name of the resource being elevated to. ResourceName string `json:"resourceName"` // Reason provided for the elevation. @@ -255,11 +253,6 @@ func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetTeam( return v.Team } -// GetTeamEnvironment returns CreateElevationCreateElevationCreateElevationPayloadElevation.TeamEnvironment, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetTeamEnvironment() CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironment { - return v.TeamEnvironment -} - // GetResourceName returns CreateElevationCreateElevationCreateElevationPayloadElevation.ResourceName, and is useful for accessing the field via an interface. func (v *CreateElevationCreateElevationCreateElevationPayloadElevation) GetResourceName() string { return v.ResourceName @@ -288,33 +281,6 @@ func (v *CreateElevationCreateElevationCreateElevationPayloadElevationTeam) GetS return v.Slug } -// CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. -type CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironment struct { - // Get the environment. - Environment CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironmentEnvironment `json:"environment"` -} - -// GetEnvironment returns CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironment.Environment, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironment) GetEnvironment() CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironmentEnvironment { - return v.Environment -} - -// CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. -// The GraphQL type's documentation follows. -// -// An environment represents a runtime environment for workloads. -// -// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). -type CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironmentEnvironment struct { - // Unique name of the environment. - Name string `json:"name"` -} - -// GetName returns CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. -func (v *CreateElevationCreateElevationCreateElevationPayloadElevationTeamEnvironmentEnvironment) GetName() string { - return v.Name -} - // Input for creating an elevation. type CreateElevationInput struct { // Input for creating an elevation. @@ -6092,9 +6058,9 @@ func (v *TeamApplicationsFilter) GetEnvironments() []string { return v.Environme type TeamMemberRole string const ( - // Regular member, read only access. + // Member, full access including elevation. TeamMemberRoleMember TeamMemberRole = "MEMBER" - // Team owner, full access to the team. + // Team owner, full access to the team including member management. TeamMemberRoleOwner TeamMemberRole = "OWNER" ) @@ -7611,6 +7577,74 @@ var AllValkeyTier = []ValkeyTier{ ValkeyTierHighAvailability, } +// Input for viewing secret values. +type ViewSecretValuesInput struct { + // Input for viewing secret values. + Name string `json:"name"` + // Input for viewing secret values. + Environment string `json:"environment"` + // Input for viewing secret values. + Team string `json:"team"` + // Input for viewing secret values. + Reason string `json:"reason"` +} + +// GetName returns ViewSecretValuesInput.Name, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesInput) GetName() string { return v.Name } + +// GetEnvironment returns ViewSecretValuesInput.Environment, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesInput) GetEnvironment() string { return v.Environment } + +// GetTeam returns ViewSecretValuesInput.Team, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesInput) GetTeam() string { return v.Team } + +// GetReason returns ViewSecretValuesInput.Reason, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesInput) GetReason() string { return v.Reason } + +// ViewSecretValuesResponse is returned by ViewSecretValues on success. +type ViewSecretValuesResponse struct { + // View the values of a secret. Requires team membership and a reason for access. + // This creates a temporary elevation and logs the access for auditing purposes. + ViewSecretValues ViewSecretValuesViewSecretValuesViewSecretValuesPayload `json:"viewSecretValues"` +} + +// GetViewSecretValues returns ViewSecretValuesResponse.ViewSecretValues, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesResponse) GetViewSecretValues() ViewSecretValuesViewSecretValuesViewSecretValuesPayload { + return v.ViewSecretValues +} + +// ViewSecretValuesViewSecretValuesViewSecretValuesPayload includes the requested fields of the GraphQL type ViewSecretValuesPayload. +// The GraphQL type's documentation follows. +// +// Payload returned when viewing secret values. +type ViewSecretValuesViewSecretValuesViewSecretValuesPayload struct { + // The secret values. + Values []ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue `json:"values"` +} + +// GetValues returns ViewSecretValuesViewSecretValuesViewSecretValuesPayload.Values, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesViewSecretValuesViewSecretValuesPayload) GetValues() []ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue { + return v.Values +} + +// ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue includes the requested fields of the GraphQL type SecretValue. +type ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue struct { + // The name of the secret value. + Name string `json:"name"` + // The secret value itself. + Value string `json:"value"` +} + +// GetName returns ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue.Name, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue) GetName() string { + return v.Name +} + +// GetValue returns ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue.Value, and is useful for accessing the field via an interface. +func (v *ViewSecretValuesViewSecretValuesViewSecretValuesPayloadValuesSecretValue) GetValue() string { + return v.Value +} + // __AddTeamMemberInput is used internally by genqlient type __AddTeamMemberInput struct { Slug string `json:"slug"` @@ -7995,6 +8029,14 @@ func (v *__UpdateValkeyInput) GetTier() ValkeyTier { return v.Tier } // GetMaxMemoryPolicy returns __UpdateValkeyInput.MaxMemoryPolicy, and is useful for accessing the field via an interface. func (v *__UpdateValkeyInput) GetMaxMemoryPolicy() ValkeyMaxMemoryPolicy { return v.MaxMemoryPolicy } +// __ViewSecretValuesInput is used internally by genqlient +type __ViewSecretValuesInput struct { + Input ViewSecretValuesInput `json:"input"` +} + +// GetInput returns __ViewSecretValuesInput.Input, and is useful for accessing the field via an interface. +func (v *__ViewSecretValuesInput) GetInput() ViewSecretValuesInput { return v.Input } + // The mutation executed by AddTeamMember. const AddTeamMember_Operation = ` mutation AddTeamMember ($slug: Slug!, $email: String!, $role: TeamMemberRole!) { @@ -8090,11 +8132,6 @@ mutation CreateElevation ($input: CreateElevationInput!) { team { slug } - teamEnvironment { - environment { - name - } - } resourceName reason } @@ -9475,3 +9512,40 @@ func Users( return data_, err_ } + +// The mutation executed by ViewSecretValues. +const ViewSecretValues_Operation = ` +mutation ViewSecretValues ($input: ViewSecretValuesInput!) { + viewSecretValues(input: $input) { + values { + name + value + } + } +} +` + +func ViewSecretValues( + ctx_ context.Context, + client_ graphql.Client, + input ViewSecretValuesInput, +) (data_ *ViewSecretValuesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ViewSecretValues", + Query: ViewSecretValues_Operation, + Variables: &__ViewSecretValuesInput{ + Input: input, + }, + } + + data_ = &ViewSecretValuesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} diff --git a/internal/naisapi/secret.go b/internal/naisapi/secret.go new file mode 100644 index 00000000..87197e49 --- /dev/null +++ b/internal/naisapi/secret.go @@ -0,0 +1,54 @@ +package naisapi + +import ( + "context" + "fmt" + + "github.com/nais/cli/internal/naisapi/gql" +) + +// SecretValue represents a key-value pair from a secret +type SecretValue struct { + Name string + Value string +} + +// ViewSecretValues retrieves the values of a secret. This requires team membership +// and a reason for access. The access is logged for auditing purposes. +func ViewSecretValues(ctx context.Context, team, environmentName, secretName, reason string) ([]SecretValue, error) { + _ = `# @genqlient +mutation ViewSecretValues($input: ViewSecretValuesInput!) { +viewSecretValues(input: $input) { +values { +name +value +} +} +} +` + + client, err := GraphqlClient(ctx) + if err != nil { + return nil, fmt.Errorf("creating GraphQL client: %w", err) + } + + resp, err := gql.ViewSecretValues(ctx, client, gql.ViewSecretValuesInput{ + Name: secretName, + Environment: environmentName, + Team: team, + Reason: reason, + }) + if err != nil { + return nil, fmt.Errorf("viewing secret values: %w", err) + } + + values := make([]SecretValue, len(resp.ViewSecretValues.Values)) + for i, v := range resp.ViewSecretValues.Values { + values[i] = SecretValue{ + Name: v.Name, + Value: v.Value, + } + } + + return values, nil +} diff --git a/internal/postgres/access.go b/internal/postgres/access.go index 9afd0d6c..b9ffe55f 100644 --- a/internal/postgres/access.go +++ b/internal/postgres/access.go @@ -35,8 +35,8 @@ var ( ) func PrepareAccess(ctx context.Context, appName string, namespace flag.Namespace, cluster flag.Context, schema string, allPrivs bool, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonPrepareAccess, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonPrepareAccess, out); err != nil { return err } @@ -55,8 +55,8 @@ func PrepareAccess(ctx context.Context, appName string, namespace flag.Namespace } func RevokeAccess(ctx context.Context, appName string, namespace flag.Namespace, cluster flag.Context, schema string, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonRevokeAccess, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonRevokeAccess, out); err != nil { return err } diff --git a/internal/postgres/audit.go b/internal/postgres/audit.go index bec5ca74..0d0922f5 100644 --- a/internal/postgres/audit.go +++ b/internal/postgres/audit.go @@ -13,16 +13,16 @@ import ( ) func EnableAuditLogging(ctx context.Context, appName string, cluster flag.Context, namespace flag.Namespace, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonEnableAudit, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonEnableAudit, out); err != nil { return err } return enableAuditAsAppUser(ctx, appName, namespace, cluster, out) } func VerifyAuditLogging(ctx context.Context, appName string, cluster flag.Context, namespace flag.Namespace, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonVerifyAudit, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonVerifyAudit, out); err != nil { return err } _, err := verifyAuditAsAppUser(ctx, appName, namespace, cluster, out) diff --git a/internal/postgres/elevation.go b/internal/postgres/elevation.go deleted file mode 100644 index 0c9c80bd..00000000 --- a/internal/postgres/elevation.go +++ /dev/null @@ -1,98 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "strings" - - "github.com/nais/cli/internal/naisapi" - "github.com/nais/cli/internal/postgres/command/flag" - "github.com/nais/naistrix" - "k8s.io/client-go/tools/clientcmd" -) - -// Hardcoded reasons for administrative operations -const ( - ReasonPasswordRotate = "Rotating database password via nais CLI" - ReasonPrepareAccess = "Preparing database for IAM user access via nais CLI" - ReasonRevokeAccess = "Revoking IAM user access from database via nais CLI" - ReasonListUsers = "Listing database users via nais CLI" - ReasonAddUser = "Adding database user via nais CLI" - ReasonDropUser = "Dropping database user via nais CLI" - ReasonEnableAudit = "Enabling audit logging via nais CLI" - ReasonVerifyAudit = "Verifying audit configuration via nais CLI" -) - -// EnsureSecretAccess creates an elevation to allow reading the database secret. -// This is required because users don't have direct access to secrets without elevation. -// The elevation is logged for audit purposes. -func EnsureSecretAccess(ctx context.Context, appName string, namespace flag.Namespace, cluster flag.Context, reason string, out *naistrix.OutputWriter) error { - if reason == "" { - return fmt.Errorf("reason is required for accessing database secrets") - } - - // Load kubeconfig to get defaults for namespace and context if not provided - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - configOverrides := &clientcmd.ConfigOverrides{ - CurrentContext: string(cluster), - } - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - - // Determine team slug from namespace - team := string(namespace) - if team == "" { - ns, _, err := kubeConfig.Namespace() - if err != nil { - return fmt.Errorf("unable to get namespace from kubeconfig: %w", err) - } - team = ns - } - if team == "" { - return fmt.Errorf("namespace is required to determine team (use --namespace flag or set in kubeconfig)") - } - - // Determine environment from kubeconfig context - environmentName := string(cluster) - if environmentName == "" { - rawConfig, err := kubeConfig.RawConfig() - if err != nil { - return fmt.Errorf("unable to get kubeconfig: %w", err) - } - environmentName = rawConfig.CurrentContext - } - if environmentName == "" { - return fmt.Errorf("kubeconfig context is required to determine environment (use --context flag or set current-context in kubeconfig)") - } - - // The secret name follows the pattern "google-sql-" - secretName := "google-sql-" + appName - - out.Debugf("Requesting elevated access to secret %q for database connection...\n", secretName) - - _, err := naisapi.CreateElevation(ctx, team, environmentName, secretName, reason, 5) - 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 fmt.Errorf("you are not authorized to access this database. Make sure you are a member of team %q", team) - } - return fmt.Errorf("creating elevation for secret access: %w", err) - } - - out.Debugf("✅ Access granted.\n") - return nil -} - -// EnsureSecretAccessWithUserReason creates an elevation 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 EnsureSecretAccessWithUserReason(ctx context.Context, appName string, namespace flag.Namespace, cluster flag.Context, reason string, out *naistrix.OutputWriter) error { - if reason == "" { - return fmt.Errorf("reason is required for accessing database secrets (use --reason flag)") - } - - if len(reason) < 10 { - return fmt.Errorf("reason must be at least 10 characters") - } - - return EnsureSecretAccess(ctx, appName, namespace, cluster, reason, out) -} diff --git a/internal/postgres/iam.go b/internal/postgres/iam.go index 05f64b22..ba988814 100644 --- a/internal/postgres/iam.go +++ b/internal/postgres/iam.go @@ -212,8 +212,8 @@ func formatCondition(expr, title string) string { } func ListUsers(ctx context.Context, appName string, cluster flag.Context, namespace flag.Namespace, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonListUsers, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonListUsers, out); err != nil { return err } @@ -261,8 +261,8 @@ func AddUser(ctx context.Context, appName, username, password string, cluster fl return err } - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonAddUser, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonAddUser, out); err != nil { return err } @@ -301,8 +301,8 @@ func AddUser(ctx context.Context, appName, username, password string, cluster fl } func DropUser(ctx context.Context, appName string, username string, cluster flag.Context, namespace flag.Namespace, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonDropUser, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonDropUser, out); err != nil { return err } diff --git a/internal/postgres/password.go b/internal/postgres/password.go index 271629e1..417cc79d 100644 --- a/internal/postgres/password.go +++ b/internal/postgres/password.go @@ -18,8 +18,8 @@ import ( ) func RotatePassword(ctx context.Context, appName string, cluster flag.Context, namespace flag.Namespace, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (hardcoded reason for administrative operation) - if err := EnsureSecretAccess(ctx, appName, namespace, cluster, ReasonPasswordRotate, out); err != nil { + // Get secret values (access is logged for audit purposes) + if _, err := GetSecretValues(ctx, appName, namespace, cluster, ReasonPasswordRotate, out); err != nil { return err } diff --git a/internal/postgres/proxy.go b/internal/postgres/proxy.go index 7b1327b4..4a31ea1b 100644 --- a/internal/postgres/proxy.go +++ b/internal/postgres/proxy.go @@ -12,8 +12,8 @@ import ( ) func RunProxy(ctx context.Context, appName string, cluster flag.Context, namespace flag.Namespace, host string, port uint, verbose bool, reason string, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (user must provide reason) - if err := EnsureSecretAccessWithUserReason(ctx, appName, namespace, cluster, reason, out); err != nil { + // Get secret values with user-provided reason (access is logged for audit purposes) + if _, err := GetSecretValuesWithUserReason(ctx, appName, namespace, cluster, reason, out); err != nil { return err } diff --git a/internal/postgres/psql.go b/internal/postgres/psql.go index b4932c0c..f0d9271d 100644 --- a/internal/postgres/psql.go +++ b/internal/postgres/psql.go @@ -12,8 +12,8 @@ import ( ) func RunPSQL(ctx context.Context, appName string, cluster flag.Context, namespace flag.Namespace, reason string, out *naistrix.OutputWriter) error { - // Ensure we have elevated access to read the database secret (user must provide reason) - if err := EnsureSecretAccessWithUserReason(ctx, appName, namespace, cluster, reason, out); err != nil { + // Get secret values with user-provided reason (access is logged for audit purposes) + if _, err := GetSecretValuesWithUserReason(ctx, appName, namespace, cluster, reason, out); err != nil { return err } diff --git a/internal/postgres/secret.go b/internal/postgres/secret.go new file mode 100644 index 00000000..20004587 --- /dev/null +++ b/internal/postgres/secret.go @@ -0,0 +1,143 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + + "github.com/nais/cli/internal/naisapi" + "github.com/nais/cli/internal/postgres/command/flag" + "github.com/nais/naistrix" + "k8s.io/client-go/tools/clientcmd" +) + +// Hardcoded reasons for administrative operations +const ( + ReasonPasswordRotate = "Rotating database password via nais CLI" + ReasonPrepareAccess = "Preparing database for IAM user access via nais CLI" + ReasonRevokeAccess = "Revoking IAM user access from database via nais CLI" + ReasonListUsers = "Listing database users via nais CLI" + ReasonAddUser = "Adding database user via nais CLI" + ReasonDropUser = "Dropping database user via nais CLI" + ReasonEnableAudit = "Enabling audit logging via nais CLI" + ReasonVerifyAudit = "Verifying audit configuration via nais CLI" +) + +// SecretValues holds the secret values retrieved from the API +type SecretValues struct { + values map[string]string +} + +// Get returns the value for a given key, or empty string if not found +func (s *SecretValues) Get(key string) string { + if s == nil || s.values == nil { + return "" + } + return s.values[key] +} + +// GetBySuffix returns the value for a key that ends with the given suffix +func (s *SecretValues) GetBySuffix(suffix string) string { + if s == nil || s.values == nil { + return "" + } + for k, v := range s.values { + if strings.HasSuffix(k, suffix) { + return v + } + } + return "" +} + +// resolveTeamAndEnvironment extracts team and environment from namespace and cluster flags +func resolveTeamAndEnvironment(namespace flag.Namespace, cluster flag.Context) (team, environment string, err error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{ + CurrentContext: string(cluster), + } + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + // Determine team slug from namespace + team = string(namespace) + if team == "" { + ns, _, err := kubeConfig.Namespace() + if err != nil { + return "", "", fmt.Errorf("unable to get namespace from kubeconfig: %w", err) + } + team = ns + } + if team == "" { + return "", "", fmt.Errorf("namespace is required to determine team (use --namespace flag or set in kubeconfig)") + } + + // Determine environment from kubeconfig context + environment = string(cluster) + if environment == "" { + rawConfig, err := kubeConfig.RawConfig() + if err != nil { + return "", "", fmt.Errorf("unable to get kubeconfig: %w", err) + } + environment = rawConfig.CurrentContext + } + if environment == "" { + return "", "", fmt.Errorf("kubeconfig context is required to determine environment (use --context flag or set current-context in kubeconfig)") + } + + return team, environment, nil +} + +// 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. +func GetSecretValues(ctx context.Context, appName string, namespace flag.Namespace, cluster flag.Context, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { + if reason == "" { + return nil, fmt.Errorf("reason is required for accessing database secrets") + } + + team, environmentName, err := resolveTeamAndEnvironment(namespace, cluster) + if err != nil { + return nil, err + } + + // The secret name follows the pattern "google-sql-" + secretName := "google-sql-" + appName + + out.Debugf("Requesting access to secret %q for database connection...\n", secretName) + + values, err := naisapi.ViewSecretValues(ctx, team, environmentName, secretName, reason) + 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("viewing secret values: %w", err) + } + + out.Debugf("✅ Access granted.\n") + + // Convert to SecretValues + result := &SecretValues{ + values: make(map[string]string, len(values)), + } + for _, v := range values { + result.values[v.Name] = v.Value + } + + 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, namespace flag.Namespace, cluster flag.Context, reason string, out *naistrix.OutputWriter) (*SecretValues, error) { + 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, namespace, cluster, reason, out) +} diff --git a/schema.graphql b/schema.graphql index 13ece1c6..a41f905c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -151,6 +151,10 @@ Secret was deleted. """ SECRET_DELETED """ +Secret values were viewed. +""" + SECRET_VALUES_VIEWED +""" Service account was created. """ SERVICE_ACCOUNT_CREATED @@ -4007,6 +4011,13 @@ Delete a secret, and the values it contains. input: DeleteSecretInput! ): DeleteSecretPayload! """ +View the values of a secret. Requires team membership and a reason for access. +This creates a temporary elevation and logs the access for auditing purposes. +""" + viewSecretValues( + input: ViewSecretValuesInput! + ): ViewSecretValuesPayload! +""" Create a service account. """ createServiceAccount( @@ -6038,9 +6049,13 @@ The team that owns the secret. """ team: Team! """ -The secret values contained within the secret. +The names of the keys in the secret. This does not require elevation to access. """ - values: [SecretValue!]! + keys: [String!]! +""" +The secret values contained within the secret. Requires elevation to access the values. Returns null if not authorized. +""" + values: [SecretValue!] """ Applications that use the secret. """ @@ -6398,6 +6413,58 @@ The name of the updated value. valueName: String! } +""" +Activity log entry for viewing secret values. +""" +type SecretValuesViewedActivityLogEntry implements ActivityLogEntry & Node{ +""" +ID of the entry. +""" + id: ID! +""" +The identity of the actor who performed the action. The value is either the name of a service account, or the email address of a user. +""" + actor: String! +""" +Creation time of the entry. +""" + createdAt: Time! +""" +Message that summarizes the entry. +""" + message: String! +""" +Type of the resource that was affected by the action. +""" + resourceType: ActivityLogEntryResourceType! +""" +Name of the resource that was affected by the action. +""" + resourceName: String! +""" +The team slug that the entry belongs to. +""" + teamSlug: Slug! +""" +The environment name that the entry belongs to. +""" + environmentName: String +""" +Data associated with the entry. +""" + data: SecretValuesViewedActivityLogEntryData! +} + +""" +Data associated with a secret values viewed activity log entry. +""" +type SecretValuesViewedActivityLogEntryData { +""" +The reason provided for viewing the secret values. +""" + reason: String! +} + """ The service account type represents machine-users of the Nais API. @@ -7300,6 +7367,10 @@ Whether or not the viewer is a member of the team. """ viewerIsMember: Boolean! """ +Whether or not the viewer can create elevations for the team. +""" + viewerCanElevate: Boolean! +""" Environments for the team. """ environments: [TeamEnvironment!]! @@ -8583,11 +8654,11 @@ Team member roles. """ enum TeamMemberRole { """ -Regular member, read only access. +Member, full access including elevation. """ MEMBER """ -Team owner, full access to the team. +Team owner, full access to the team including member management. """ OWNER } @@ -10045,6 +10116,38 @@ The new value of the field. newValue: String } +""" +Input for viewing secret values. +""" +input ViewSecretValuesInput { +""" +Input for viewing secret values. +""" + name: String! +""" +Input for viewing secret values. +""" + environment: String! +""" +Input for viewing secret values. +""" + team: Slug! +""" +Input for viewing secret values. +""" + reason: String! +} + +""" +Payload returned when viewing secret values. +""" +type ViewSecretValuesPayload { +""" +The secret values. +""" + values: [SecretValue!]! +} + type VulnerabilityActivityLogEntryData { """ The unique identifier of the vulnerability. E.g. CVE-****-****.