diff --git a/agent.codefly.yaml b/agent.codefly.yaml index 6b07177..684b721 100644 --- a/agent.codefly.yaml +++ b/agent.codefly.yaml @@ -1,4 +1,4 @@ publisher: codefly.dev kind: codefly:service name: postgres -version: 0.0.116 +version: 0.0.117 diff --git a/bootstrap_template_test.go b/bootstrap_template_test.go index 0a4a3bb..5733803 100644 --- a/bootstrap_template_test.go +++ b/bootstrap_template_test.go @@ -2,10 +2,15 @@ package main import ( "bytes" + "fmt" "io/fs" + "os" + "os/exec" + "path/filepath" "strings" "testing" "text/template" + "time" ) func TestBootstrapImageAlwaysReconcilesRuntimeAccess(t *testing.T) { @@ -38,8 +43,18 @@ func TestBootstrapImageAlwaysReconcilesRuntimeAccess(t *testing.T) { ) { t.Fatal("bootstrap image does not wait for Postgres readiness") } - if !strings.Contains(dockerfile, "/releases/download/v4.19.1/migrate.linux-amd64.tar.gz") { - t.Fatal("bootstrap image does not pin the supported migration runtime") + for _, required := range []string{ + "ARG TARGETARCH", + `architecture="${TARGETARCH:-$(apk --print-arch)}"`, + "x86_64) architecture=amd64", + "aarch64) architecture=arm64", + `case "${architecture}" in`, + "amd64|arm64)", + "/releases/download/v4.19.1/migrate.linux-${architecture}.tar.gz", + } { + if !strings.Contains(dockerfile, required) { + t.Fatalf("bootstrap image is not target-architecture portable: missing %q", required) + } } hasMigration := strings.Contains(dockerfile, "/usr/local/bin/migrate -path") if hasMigration != test.withMigrations { @@ -70,6 +85,92 @@ func TestBootstrapImageAlwaysReconcilesRuntimeAccess(t *testing.T) { } } +func TestBootstrapImageBuildsWhenDockerOmitsTargetArchitecture(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Fatal(err) + } + root := t.TempDir() + parameters := DockerTemplating{ + MigrationConnectionKeyHolder: "{" + migrationConnectionEnvironmentKey + "}", + } + if err := os.WriteFile( + filepath.Join(root, "Dockerfile"), + []byte(renderBuilderTemplate(t, "templates/builder/Dockerfile.tmpl", parameters)), + 0o644, + ); err != nil { + t.Fatal(err) + } + builderDir := filepath.Join(root, "builder") + if err := os.MkdirAll(builderDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(builderDir, "runtime-access.sql"), []byte("SELECT 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + tag := fmt.Sprintf("service-postgres-bootstrap-targetarch-test:%d", time.Now().UnixNano()) + t.Cleanup(func() { + _ = exec.Command("docker", "image", "rm", tag).Run() + }) + command := exec.Command("docker", "build", "--build-arg", "TARGETARCH=", "--tag", tag, root) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("legacy Docker build without TARGETARCH failed: %v\n%s", err, output) + } +} + +func TestRuntimeAccessTemplateUsesDelegatedRolesAsExclusiveWriteAuthority(t *testing.T) { + parameters := DockerTemplating{ + MigrationConnectionKeyHolder: "{" + migrationConnectionEnvironmentKey + "}", + ReadOnlyRole: "codefly_app_ro", + ReadWriteRole: "codefly_app_rw", + Schemas: []string{"public"}, + ReadWriteRoles: []string{"app_tenant", "app_worker"}, + } + + accessSQL := renderBuilderTemplate(t, "templates/builder/runtime-access.sql.tmpl", parameters) + for _, forbidden := range []string{ + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES", + "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES", + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES", + "GRANT USAGE, SELECT, UPDATE ON SEQUENCES", + } { + if strings.Contains(accessSQL, forbidden) { + t.Fatalf("delegated read-write login retained direct authority %q", forbidden) + } + } + for _, required := range []string{ + "REVOKE ALL PRIVILEGES ON ALL TABLES", + "REVOKE ALL PRIVILEGES ON ALL SEQUENCES", + "GRANT %I TO %I", + "app_tenant", + "app_worker", + } { + if !strings.Contains(accessSQL, required) { + t.Fatalf("delegated runtime access is missing %q", required) + } + } +} + +func TestRuntimeAccessTemplatePreservesDirectWriterWithoutDelegatedRoles(t *testing.T) { + parameters := DockerTemplating{ + MigrationConnectionKeyHolder: "{" + migrationConnectionEnvironmentKey + "}", + ReadOnlyRole: "codefly_app_ro", + ReadWriteRole: "codefly_app_rw", + Schemas: []string{"public"}, + } + + accessSQL := renderBuilderTemplate(t, "templates/builder/runtime-access.sql.tmpl", parameters) + for _, required := range []string{ + "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES", + "GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES", + "GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES", + "GRANT USAGE, SELECT, UPDATE ON SEQUENCES", + } { + if !strings.Contains(accessSQL, required) { + t.Fatalf("direct runtime access is missing %q", required) + } + } +} + func renderBuilderTemplate(t *testing.T, name string, parameters DockerTemplating) string { t.Helper() source, err := fs.ReadFile(builderFS, name) diff --git a/builder.go b/builder.go index 3bd0aa8..8767162 100644 --- a/builder.go +++ b/builder.go @@ -1,9 +1,15 @@ package main import ( + "bytes" "context" + "crypto/sha256" "embed" + "encoding/hex" "fmt" + "io/fs" + "strings" + "text/template" "github.com/codefly-dev/core/agents/communicate" dockerhelpers "github.com/codefly-dev/core/agents/helpers/docker" @@ -16,6 +22,7 @@ import ( "github.com/codefly-dev/core/agents/services/upgrade" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/shared" + "gopkg.in/yaml.v3" ) type Builder struct { @@ -180,7 +187,7 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) ManagedImage: s.dockerImage().FullName(), DatabaseName: s.DatabaseName, } - var promotableConfiguration *v0.Configuration + var restrictedConfiguration *v0.Configuration response, err := s.Builder.DeployKustomize(ctx, req, services.KustomizeDeployment{ EnvironmentVariables: s.EnvironmentVariables, Templates: deploymentFS, @@ -190,9 +197,14 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) if prepareErr != nil { return prepareErr } + bootstrapJobName, nameErr := s.immutableBootstrapJobName(deployment, parameters) + if nameErr != nil { + return nameErr + } + parameters.BootstrapJobName = bootstrapJobName s.Wool.Debug("exporting configuration", wool.Field("conf", resources.MakeConfigurationSummary(configuration))) - if deployment.Profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { - promotableConfiguration = configuration + if services.IsRestrictedOutputProfile(deployment.Profile) { + restrictedConfiguration = configuration return nil } return deployment.ExportConfiguration(ctx, configuration) @@ -200,13 +212,71 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) }) if err != nil || response.GetState().GetState() != builderv0.DeploymentStatus_SUCCESS || - promotableConfiguration == nil { + restrictedConfiguration == nil { return response, err } - response.Configuration = promotableConfiguration + response.Configuration = restrictedConfiguration return response, nil } +const bootstrapJobTemplatePath = "templates/deployment/kustomize/base/job.yaml.tmpl" + +func (s *Builder) immutableBootstrapJobName( + deployment *services.KustomizeDeploymentContext, + parameters *DeploymentTemplateParameters, +) (string, error) { + service := shared.ToDNSCase(s.Identity.Name) + if service == "" { + return "", fmt.Errorf("bootstrap service name is required") + } + + source, err := fs.ReadFile(deploymentFS, bootstrapJobTemplatePath) + if err != nil { + return "", fmt.Errorf("read bootstrap Job template: %w", err) + } + jobTemplate, err := template.New(bootstrapJobTemplatePath).Parse(string(source)) + if err != nil { + return "", fmt.Errorf("parse bootstrap Job template: %w", err) + } + renderContext := &services.DeploymentWrapper{ + DeploymentBase: &services.DeploymentBase{ + Information: s.Information, + Namespace: deployment.Kubernetes.GetNamespace(), + Image: s.DockerImage(deployment.Kubernetes.GetBuildContext()), + Profile: deployment.Profile, + Restricted: services.IsRestrictedOutputProfile(deployment.Profile), + }, + Deployment: services.DeploymentParameters{Parameters: parameters}, + } + var rendered bytes.Buffer + if err = jobTemplate.Execute(&rendered, renderContext); err != nil { + return "", fmt.Errorf("render bootstrap Job template: %w", err) + } + var job struct { + Spec struct { + Template yaml.Node `yaml:"template"` + } `yaml:"spec"` + } + if err = yaml.Unmarshal(rendered.Bytes(), &job); err != nil { + return "", fmt.Errorf("parse rendered bootstrap Job: %w", err) + } + if job.Spec.Template.Kind == 0 { + return "", fmt.Errorf("rendered bootstrap Job is missing spec.template") + } + podTemplate, err := yaml.Marshal(&job.Spec.Template) + if err != nil { + return "", fmt.Errorf("encode bootstrap Job pod template: %w", err) + } + contentDigest := sha256.Sum256(podTemplate) + + const suffixLength = 12 + const maxServiceLength = 63 - 1 - suffixLength + if len(service) > maxServiceLength { + service = strings.TrimRight(service[:maxServiceLength], "-") + } + return service + "-" + hex.EncodeToString(contentDigest[:])[:suffixLength], nil +} + func (s *Builder) prepareDeployment( ctx context.Context, deployment *services.KustomizeDeploymentContext, @@ -222,7 +292,7 @@ func (s *Builder) prepareDeployment( if err != nil { return nil, err } - if deployment.Profile == builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1 { + if services.IsRestrictedOutputProfile(deployment.Profile) { workloadReferences, referencesErr := s.selectPromotableSecretReferences( deployment.Kubernetes.GetSecretReferences(), ) diff --git a/capability_probe_test.go b/capability_probe_test.go index c545f71..5b9187f 100644 --- a/capability_probe_test.go +++ b/capability_probe_test.go @@ -44,6 +44,21 @@ func (p *postgresCapabilityProbe) AppendTenantFixture(ctx context.Context, relat return err } +func (p *postgresCapabilityProbe) AppendFixtureAsRole(ctx context.Context, role, relation, id string) error { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `SET LOCAL ROLE `+pq.QuoteIdentifier(role)); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO `+pq.QuoteIdentifier(relation)+` (id) VALUES ($1)`, id); err != nil { + return err + } + return tx.Commit() +} + func (p *postgresCapabilityProbe) HasFixture(ctx context.Context, relation, id string) (bool, error) { var exists bool err := p.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM `+pq.QuoteIdentifier(relation)+` WHERE id = $1)`, id).Scan(&exists) @@ -91,6 +106,20 @@ func (p *postgresCapabilityProbe) InstallTenantFixture(ctx context.Context, rela return nil } +func (p *postgresCapabilityProbe) InstallDelegatedWriteRole(ctx context.Context, role, relation string) error { + quotedRole := pq.QuoteIdentifier(role) + quotedRelation := pq.QuoteIdentifier(relation) + for _, statement := range []string{ + `CREATE ROLE ` + quotedRole + ` NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ` + quotedRelation + ` TO ` + quotedRole, + } { + if _, err := p.db.ExecContext(ctx, statement); err != nil { + return err + } + } + return nil +} + type scopedFixtureRepository struct { factory *scoped.Factory relation string diff --git a/deployment_test.go b/deployment_test.go index b9cc8ff..3421704 100644 --- a/deployment_test.go +++ b/deployment_test.go @@ -13,12 +13,14 @@ import ( builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/resources" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestDeploymentTemplatesWithMigration(t *testing.T) { dir := agenttesting.AssertKustomizeTemplates(t, deploymentFS, DeploymentTemplateParameters{ - WithBootstrap: true, - ManagedImage: image.FullName(), + WithBootstrap: true, + ManagedImage: image.FullName(), + BootstrapJobName: "postgres-aaaaaaaaaaaa", }) assertMigrationResource(t, dir, true) assertEphemeralSecret(t, dir) @@ -26,7 +28,8 @@ func TestDeploymentTemplatesWithMigration(t *testing.T) { func TestDeploymentTemplatesWithoutBootstrap(t *testing.T) { dir := agenttesting.AssertKustomizeTemplates(t, deploymentFS, DeploymentTemplateParameters{ - ManagedImage: image.FullName(), + ManagedImage: image.FullName(), + BootstrapJobName: "postgres-aaaaaaaaaaaa", }) assertMigrationResource(t, dir, false) } @@ -81,7 +84,7 @@ func TestPromotableDeploymentUsesTypedSecretReferencesWithoutValues(t *testing.T Kubernetes: &builderv0.KubernetesDeployment{ Namespace: "platform", Destination: destination, - Profile: builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + Profile: builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_RESTRICTED_PORTABLE_V1, SecretReferences: secretReferences, BuildContext: &builderv0.DockerBuildContext{ DockerRepository: "registry.example.com", @@ -96,8 +99,8 @@ func TestPromotableDeploymentUsesTypedSecretReferencesWithoutValues(t *testing.T if response.GetState().GetState() != builderv0.DeploymentStatus_SUCCESS { t.Fatalf("deployment failed: %s", response.GetState().GetMessage()) } - if !response.GetDeployment().GetKubernetes().GetValidation().GetPromotable() { - t.Fatal("deployment is not promotable") + if !response.GetDeployment().GetKubernetes().GetValidation().GetRestricted() { + t.Fatal("deployment is not restricted") } for _, value := range response.GetConfiguration().GetInfos()[0].GetConfigurationValues() { if !value.GetSecret() || value.GetValue() != "" { @@ -139,12 +142,13 @@ func TestEphemeralDeploymentRetainsValueBasedConfigurationAndSecret(t *testing.T destination := t.TempDir() request := promotableDeploymentRequest(destination, networkMappings, nil) request.GetDeployment().GetKubernetes().Profile = builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 + request.GetDeployment().GetKubernetes().BuildContext.ImageDigest = "" request.Configuration = testPostgresConfiguration("migration-owner", "owner-secret", "reader-secret", "writer-secret") response, err := builder.Deploy(context.Background(), request) require.NoError(t, err) require.Equal(t, builderv0.DeploymentStatus_SUCCESS, response.GetState().GetState(), response.GetState().GetMessage()) - require.False(t, response.GetDeployment().GetKubernetes().GetValidation().GetPromotable()) + require.False(t, response.GetDeployment().GetKubernetes().GetValidation().GetRestricted()) require.NotEmpty(t, configurationValue(t, response.GetConfiguration(), ownerConnectionKey)) secret := readDeploymentFile(t, destination, "overlays", "test", "secret.yaml") @@ -152,6 +156,10 @@ func TestEphemeralDeploymentRetainsValueBasedConfigurationAndSecret(t *testing.T require.Contains(t, secret, "POSTGRES_PASSWORD: b3duZXItc2VjcmV0") require.Contains(t, secret, "POSTGRES_READ_ONLY_PASSWORD: cmVhZGVyLXNlY3JldA==") require.Contains(t, secret, "POSTGRES_READ_WRITE_PASSWORD: d3JpdGVyLXNlY3JldA==") + + job := readDeploymentFile(t, destination, "base", "job.yaml") + require.Contains(t, job, "registry.example.com/module/postgres") + require.Regexp(t, `^postgres-[0-9a-f]{12}$`, bootstrapJobResourceName(t, job)) } func assertMigrationResource(t *testing.T, dir string, expected bool) { @@ -175,11 +183,11 @@ func TestPromotableGitOpsDeploymentReturnsReferenceOnlyConfigurationAndScopesSec require.Equal(t, builderv0.DeploymentStatus_SUCCESS, response.GetState().GetState(), response.GetState().GetMessage()) output := response.GetDeployment().GetKubernetes() - require.Equal(t, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, output.GetProfile()) + require.Equal(t, builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_RESTRICTED_PORTABLE_V1, output.GetProfile()) require.Equal(t, services.KubernetesManifestContractVersion, output.GetContractVersion()) require.Equal(t, builderv0.KubernetesManifestValidation_STATUS_PASSED, output.GetValidation().GetStaticValidation()) require.Equal(t, builderv0.KubernetesManifestValidation_STATUS_NOT_RUN, output.GetValidation().GetServerSideValidation()) - require.True(t, output.GetValidation().GetPromotable()) + require.True(t, output.GetValidation().GetRestricted()) configuration := response.GetConfiguration() require.Equal(t, builder.Unique(), configuration.GetOrigin()) @@ -262,6 +270,65 @@ func TestPromotableGitOpsDeploymentReturnsReferenceOnlyConfigurationAndScopesSec } { require.NotContains(t, job, unexpected) } + require.Regexp(t, `^postgres-[0-9a-f]{12}$`, bootstrapJobResourceName(t, job)) +} + +func TestPromotableBootstrapJobIdentityChangesWithImageDigest(t *testing.T) { + render := func(digest string) string { + t.Helper() + builder, networkMappings := newDeploymentTestBuilder(t) + destination := t.TempDir() + request := promotableDeploymentRequest(destination, networkMappings, promotablePostgresSecretReferences()) + request.GetDeployment().GetKubernetes().BuildContext.ImageDigest = digest + + response, err := builder.Deploy(context.Background(), request) + require.NoError(t, err) + require.Equal(t, builderv0.DeploymentStatus_SUCCESS, response.GetState().GetState(), response.GetState().GetMessage()) + return readDeploymentFile(t, destination, "base", "job.yaml") + } + + first := render("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + second := render("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + require.NotEqual(t, bootstrapJobResourceName(t, first), bootstrapJobResourceName(t, second)) +} + +func TestPromotableBootstrapJobIdentityChangesWithImmutablePodTemplate(t *testing.T) { + render := func(configure func(*Builder, *builderv0.DeploymentRequest)) string { + t.Helper() + builder, networkMappings := newDeploymentTestBuilder(t) + destination := t.TempDir() + request := promotableDeploymentRequest(destination, networkMappings, promotablePostgresSecretReferences()) + if configure != nil { + configure(builder, request) + } + response, err := builder.Deploy(context.Background(), request) + require.NoError(t, err) + require.Equal(t, builderv0.DeploymentStatus_SUCCESS, response.GetState().GetState(), response.GetState().GetMessage()) + return readDeploymentFile(t, destination, "base", "job.yaml") + } + + baseline := bootstrapJobResourceName(t, render(nil)) + require.Equal(t, baseline, bootstrapJobResourceName(t, render(nil))) + for name, configure := range map[string]func(*Builder, *builderv0.DeploymentRequest){ + "image repository": func(_ *Builder, request *builderv0.DeploymentRequest) { + request.GetDeployment().GetKubernetes().BuildContext.DockerRepository = "mirror.example.com" + }, + "Secret references": func(_ *Builder, request *builderv0.DeploymentRequest) { + for _, reference := range request.GetDeployment().GetKubernetes().SecretReferences { + if reference.GetName() == "postgres-secrets" { + reference.Name = "renamed-postgres-secrets" + } + } + }, + "database name": func(builder *Builder, _ *builderv0.DeploymentRequest) { + builder.DatabaseName = "accounts" + }, + } { + t.Run(name, func(t *testing.T) { + changed := bootstrapJobResourceName(t, render(configure)) + require.NotEqual(t, baseline, changed) + }) + } } func TestPromotableGitOpsDeploymentReportsExplicitValidationContext(t *testing.T) { @@ -283,7 +350,7 @@ func TestPromotableGitOpsDeploymentReportsExplicitValidationContext(t *testing.T validation := response.GetDeployment().GetKubernetes().GetValidation() require.Equal(t, builderv0.KubernetesManifestValidation_STATUS_PASSED, validation.GetServerSideValidation()) require.Equal(t, "k3d-codefly-test", validation.GetValidatedContext()) - require.True(t, validation.GetPromotable()) + require.True(t, validation.GetRestricted()) } func TestPromotableGitOpsDeploymentRejectsMissingOrOptionalRequiredSecretReferences(t *testing.T) { @@ -384,7 +451,7 @@ func promotableDeploymentRequest( Namespace: "codefly-test", Destination: destination, BuildContext: &builderv0.DockerBuildContext{DockerRepository: "registry.example.com", ImageDigest: digest}, - Profile: builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_PROMOTABLE_GITOPS_V1, + Profile: builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_RESTRICTED_PORTABLE_V1, SecretReferences: secretReferences, }, }, @@ -433,6 +500,18 @@ func assertEphemeralSecret(t *testing.T, dir string) { require.Contains(t, secret, "CODEFLY_TEST_SECRET: c2VjcmV0") } +func bootstrapJobResourceName(t *testing.T, manifest string) string { + t.Helper() + var job struct { + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + } + require.NoError(t, yaml.Unmarshal([]byte(manifest), &job)) + require.NotEmpty(t, job.Metadata.Name) + return job.Metadata.Name +} + func readDeploymentFile(t *testing.T, directory string, elements ...string) string { t.Helper() content, err := os.ReadFile(filepath.Join(append([]string{directory}, elements...)...)) diff --git a/go.mod b/go.mod index b12a245..bc3b2b6 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.12 toolchain go1.26.5 require ( - github.com/codefly-dev/core v0.2.52 + github.com/codefly-dev/core v0.2.59 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 github.com/lib/pq v1.12.3 diff --git a/go.sum b/go.sum index 30f8800..467851c 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codefly-dev/core v0.2.52 h1:bHudneVK/yLMxEc163v9Hm590E1Z6x7HWkZVdoA3CIA= -github.com/codefly-dev/core v0.2.52/go.mod h1:hHJm+wOsHxpxKn4UMiFqBrGy0BE56iby9yptfygbdR4= +github.com/codefly-dev/core v0.2.59 h1:uEOoYFNRf6q7unRH37cDM7ccZuQ6QTDIHnuoDfGoDnA= +github.com/codefly-dev/core v0.2.59/go.mod h1:hHJm+wOsHxpxKn4UMiFqBrGy0BE56iby9yptfygbdR4= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/libs/go/controlplane/access.go b/libs/go/controlplane/access.go index af67fe4..126300a 100644 --- a/libs/go/controlplane/access.go +++ b/libs/go/controlplane/access.go @@ -15,14 +15,6 @@ import ( "github.com/lib/pq" ) -// SQLExecutor is the transaction surface required to reconcile runtime roles. -// *sql.Tx satisfies it; callers retain ownership of commit and rollback. -type SQLExecutor interface { - ExecContext(context.Context, string, ...any) (sql.Result, error) - QueryContext(context.Context, string, ...any) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...any) *sql.Row -} - // RuntimeAccess describes the least-privilege application roles for one // database. Roles must already exist; login-role creation and password rotation // remain service-runtime responsibilities. @@ -39,15 +31,17 @@ type RuntimeAccess struct { ReconcileReadWriteRoleMemberships bool } -// ReconcileRuntimeAccess grants only CONNECT/USAGE/query/DML capabilities, -// revokes schema creation, installs matching default privileges, and reconciles -// explicitly configured NOLOGIN roles assumed by the read-write principal. -func ReconcileRuntimeAccess(ctx context.Context, executor SQLExecutor, access RuntimeAccess) error { +// ReconcileRuntimeAccess grants only CONNECT/USAGE/query capabilities, revokes +// schema creation, and installs matching default privileges. The read-write +// principal receives direct DML only when no delegated roles are configured; +// otherwise its exclusive write authority is the reconciled NOLOGIN role set. +// The caller owns the transaction and must roll it back on any returned error. +func ReconcileRuntimeAccess(ctx context.Context, tx *sql.Tx, access RuntimeAccess) error { if ctx == nil { return errors.New("runtime-access context is required") } - if executor == nil { - return errors.New("runtime-access SQL executor is required") + if tx == nil { + return errors.New("runtime-access SQL transaction is required") } if err := validateRuntimeAccess(access); err != nil { return err @@ -64,7 +58,7 @@ func ReconcileRuntimeAccess(ctx context.Context, executor SQLExecutor, access Ru `GRANT CONNECT ON DATABASE ` + database + ` TO ` + readWrite, } for _, statement := range databaseStatements { - if _, err := executor.ExecContext(ctx, statement); err != nil { + if _, err := tx.ExecContext(ctx, statement); err != nil { return err } } @@ -82,18 +76,22 @@ func ReconcileRuntimeAccess(ctx context.Context, executor SQLExecutor, access Ru `REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ` + schema + ` FROM ` + readOnly, `REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ` + schema + ` FROM ` + readWrite, `GRANT SELECT ON ALL TABLES IN SCHEMA ` + schema + ` TO ` + readOnly, - `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ` + schema + ` TO ` + readWrite, - `GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA ` + schema + ` TO ` + readWrite, `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` REVOKE ALL ON TABLES FROM ` + readOnly, `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` REVOKE ALL ON TABLES FROM ` + readWrite, `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` REVOKE ALL ON SEQUENCES FROM ` + readOnly, `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` REVOKE ALL ON SEQUENCES FROM ` + readWrite, `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` GRANT SELECT ON TABLES TO ` + readOnly, - `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ` + readWrite, - `ALTER DEFAULT PRIVILEGES FOR ROLE ` + owner + ` IN SCHEMA ` + schema + ` GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ` + readWrite, + } + if len(access.ReadWriteRoles) == 0 { + statements = append(statements, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA `+schema+` TO `+readWrite, + `GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA `+schema+` TO `+readWrite, + `ALTER DEFAULT PRIVILEGES FOR ROLE `+owner+` IN SCHEMA `+schema+` GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO `+readWrite, + `ALTER DEFAULT PRIVILEGES FOR ROLE `+owner+` IN SCHEMA `+schema+` GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO `+readWrite, + ) } for _, statement := range statements { - if _, err := executor.ExecContext(ctx, statement); err != nil { + if _, err := tx.ExecContext(ctx, statement); err != nil { return err } } @@ -101,7 +99,7 @@ func ReconcileRuntimeAccess(ctx context.Context, executor SQLExecutor, access Ru if !access.ReconcileReadWriteRoleMemberships { return nil } - return reconcileRuntimeRoleMemberships(ctx, executor, access.ReadWriteRole, access.ReadWriteRoles) + return reconcileRuntimeRoleMemberships(ctx, tx, access.ReadWriteRole, access.ReadWriteRoles) } func validateRuntimeAccess(access RuntimeAccess) error { @@ -132,8 +130,8 @@ func validateRuntimeAccess(access RuntimeAccess) error { return nil } -func reconcileRuntimeRoleMemberships(ctx context.Context, executor SQLExecutor, member string, configured []string) error { - rows, err := executor.QueryContext(ctx, ` +func reconcileRuntimeRoleMemberships(ctx context.Context, tx *sql.Tx, member string, configured []string) error { + rows, err := tx.QueryContext(ctx, ` SELECT granted.rolname FROM pg_auth_members membership JOIN pg_roles granted ON granted.oid = membership.roleid @@ -159,14 +157,14 @@ func reconcileRuntimeRoleMemberships(ctx context.Context, executor SQLExecutor, return err } for _, role := range current { - if _, err := executor.ExecContext(ctx, `REVOKE `+pq.QuoteIdentifier(role)+` FROM `+pq.QuoteIdentifier(member)); err != nil { + if _, err := tx.ExecContext(ctx, `REVOKE `+pq.QuoteIdentifier(role)+` FROM `+pq.QuoteIdentifier(member)); err != nil { return err } } for _, role := range configured { var canLogin, superuser, createDatabase, createRole bool - err := executor.QueryRowContext(ctx, ` + err := tx.QueryRowContext(ctx, ` SELECT rolcanlogin, rolsuper, rolcreatedb, rolcreaterole FROM pg_roles WHERE rolname = $1`, role).Scan(&canLogin, &superuser, &createDatabase, &createRole) @@ -179,7 +177,7 @@ func reconcileRuntimeRoleMemberships(ctx context.Context, executor SQLExecutor, if canLogin || superuser || createDatabase || createRole { return fmt.Errorf("configured runtime read-write role %q must be NOLOGIN, NOSUPERUSER, NOCREATEDB, and NOCREATEROLE", role) } - if _, err := executor.ExecContext(ctx, `GRANT `+pq.QuoteIdentifier(role)+` TO `+pq.QuoteIdentifier(member)); err != nil { + if _, err := tx.ExecContext(ctx, `GRANT `+pq.QuoteIdentifier(role)+` TO `+pq.QuoteIdentifier(member)); err != nil { return err } } diff --git a/libs/go/controlplane/access_test.go b/libs/go/controlplane/access_test.go index d65ed5c..2c5bd60 100644 --- a/libs/go/controlplane/access_test.go +++ b/libs/go/controlplane/access_test.go @@ -2,9 +2,12 @@ package controlplane import ( "context" + "database/sql" "testing" ) +var _ func(context.Context, *sql.Tx, RuntimeAccess) error = ReconcileRuntimeAccess + func TestReconcileRuntimeAccessFailsClosedBeforeSQL(t *testing.T) { valid := RuntimeAccess{ Database: "application", OwnerRole: "owner", ReadOnlyRole: "reader", ReadWriteRole: "writer", Schemas: []string{"public"}, @@ -13,7 +16,7 @@ func TestReconcileRuntimeAccessFailsClosedBeforeSQL(t *testing.T) { t.Fatal("nil context was accepted") } if err := ReconcileRuntimeAccess(context.Background(), nil, valid); err == nil { - t.Fatal("nil executor was accepted") + t.Fatal("nil transaction was accepted") } for name, mutate := range map[string]func(*RuntimeAccess){ "database": func(access *RuntimeAccess) { access.Database = "" }, diff --git a/main.go b/main.go index 9a43b75..61e3580 100644 --- a/main.go +++ b/main.go @@ -66,9 +66,11 @@ type Settings struct { // RuntimeReadWriteRoles is the explicit allow-list of application-defined // NOLOGIN roles that the managed read-write principal may assume with - // SET ROLE. The roles must be created by migrations. This lets an - // application keep request, worker, and RLS capabilities in its own schema - // contract without exporting the database-owner credential. + // SET ROLE. When non-empty, these roles are the principal's exclusive source + // of DML authority; the managed login receives no direct table or sequence + // grants. The roles must be created by migrations. This lets an application + // keep request, worker, and RLS capabilities in its own schema contract + // without exporting the database-owner credential. RuntimeReadWriteRoles []string `yaml:"runtime-read-write-roles"` // MigrationSources lets SEVERAL services share this ONE database while each @@ -142,6 +144,7 @@ func parseRuntimeImageLock(content []byte) (*resources.DockerImage, error) { type DeploymentTemplateParameters struct { WithBootstrap bool ManagedImage string + BootstrapJobName string DatabaseName string StatefulSetSecretReferences map[string]*builderv0.KubernetesSecretKeyReference BootstrapJobSecretReferences map[string]*builderv0.KubernetesSecretKeyReference diff --git a/main_test.go b/main_test.go index 0d79208..29bb87d 100644 --- a/main_test.go +++ b/main_test.go @@ -267,6 +267,33 @@ func testCreateToRun(t *testing.T, runtimeContext *basev0.RuntimeContext) { require.False(t, found, "tenant-b workload must not see tenant-a data") require.Error(t, repository.Put(workloadB.Context(ctx), "blocked", "value"), "read-only workload must not obtain a writer") + runtime.RuntimeReadWriteRoles = []string{"missing_app_writer"} + require.ErrorContains( + t, + runtime.ensureRuntimeAccess(ctx), + `configured runtime read-write role "missing_app_writer" does not exist`, + ) + require.NoError( + t, + writer.AppendFixture(ctx, serviceName, "00000000-0000-0000-0000-000000000006"), + "failed delegated-role reconciliation must preserve the writer's prior authority", + ) + + const delegatedWriter = "app_writer" + require.NoError(t, owner.InstallDelegatedWriteRole(ctx, delegatedWriter, serviceName)) + runtime.RuntimeReadWriteRoles = []string{delegatedWriter} + require.NoError(t, runtime.ensureRuntimeAccess(ctx)) + require.Error( + t, + writer.AppendFixture(ctx, serviceName, "00000000-0000-0000-0000-000000000004"), + "delegated writer must not retain direct table authority", + ) + require.NoError( + t, + writer.AppendFixtureAsRole(ctx, delegatedWriter, serviceName, "00000000-0000-0000-0000-000000000005"), + "delegated writer must mutate through an explicitly configured role", + ) + if runtimeContext.Kind == resources.RuntimeContextContainer { assertDockerStateSurvivesContainerRecreation( t, diff --git a/runtime_access.go b/runtime_access.go index 91d969d..b1f3c4b 100644 --- a/runtime_access.go +++ b/runtime_access.go @@ -180,8 +180,9 @@ func validSQLIdentifier(value string) bool { // ensureRuntimeAccess reconciles the least-privilege runtime credentials // exported to dependent services. Both roles are non-owner, non-superuser, // NOBYPASSRLS principals. -// The read-only role has SELECT grants only; the read-write role has DML grants -// but no schema CREATE or role-management authority. +// The read-only role has SELECT grants only. The read-write role has direct DML +// only in generic mode; delegated mode grants it only explicit SET ROLE +// memberships. Neither role has schema CREATE or role-management authority. func (s *Runtime) ensureRuntimeAccess(ctx context.Context) error { schemas, err := normalizedRuntimeSchemas(s.Settings.RuntimeSchemas) if err != nil { diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 1324ecc..fef5079 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -3,7 +3,7 @@ This service provisions Postgres as two explicit runtime capabilities: - `postgres.read-only-connection`: a non-owner `NOBYPASSRLS` role with query-only grants and a read-only transaction default. -- `postgres.read-write-connection`: a non-owner `NOBYPASSRLS` role with query and DML grants, but no schema creation, database creation, role management, or owner membership. +- `postgres.read-write-connection`: a non-owner `NOBYPASSRLS` role with no schema creation, database creation, role management, or owner membership. By default it receives query and DML grants directly. When `runtime-read-write-roles` is configured, those explicit `NOLOGIN` roles are its exclusive application authority and callers must select one with `SET ROLE`. The migration owner is private to the Postgres service and its bootstrap job. It is never exported to dependent services. Newly generated services receive separate owner, reader, and writer secrets. Legacy owner-only services derive stable, domain-separated reader and writer credentials inside the agent so isolated checkouts start without copying developer-local configuration; explicit runtime secrets always take precedence. diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index 221f921..7ceb9e0 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -1,10 +1,19 @@ FROM alpine:3.21 +ARG TARGETARCH + WORKDIR /app RUN apk add --no-cache curl postgresql17-client -RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz -RUN mv migrate /usr/local/bin/migrate +RUN architecture="${TARGETARCH:-$(apk --print-arch)}" \ + && case "${architecture}" in \ + x86_64) architecture=amd64 ;; \ + aarch64) architecture=arm64 ;; \ + amd64|arm64) ;; \ + *) echo "unsupported target architecture: ${architecture}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-${architecture}.tar.gz" | tar xz \ + && mv migrate /usr/local/bin/migrate COPY . . diff --git a/templates/builder/runtime-access.sql.tmpl b/templates/builder/runtime-access.sql.tmpl index b78bf61..135ab12 100644 --- a/templates/builder/runtime-access.sql.tmpl +++ b/templates/builder/runtime-access.sql.tmpl @@ -56,10 +56,12 @@ SELECT format('REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I FROM %I', '{{ \gexec SELECT format('GRANT SELECT ON ALL TABLES IN SCHEMA %I TO %I', '{{ . }}', '{{ $.ReadOnlyRole }}') \gexec +{{- if not $.ReadWriteRoles }} SELECT format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO %I', '{{ . }}', '{{ $.ReadWriteRole }}') \gexec SELECT format('GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA %I TO %I', '{{ . }}', '{{ $.ReadWriteRole }}') \gexec +{{- end }} SELECT format('ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL ON TABLES FROM %I', :'owner_user', '{{ . }}', '{{ $.ReadOnlyRole }}') \gexec @@ -71,14 +73,16 @@ SELECT format('ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL ON S \gexec SELECT format('ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT SELECT ON TABLES TO %I', :'owner_user', '{{ . }}', '{{ $.ReadOnlyRole }}') \gexec +{{- if not $.ReadWriteRoles }} SELECT format('ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO %I', :'owner_user', '{{ . }}', '{{ $.ReadWriteRole }}') \gexec SELECT format('ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO %I', :'owner_user', '{{ . }}', '{{ $.ReadWriteRole }}') \gexec {{- end }} +{{- end }} --- The managed login owns no application capabilities implicitly. Reconcile --- its SET ROLE allow-list from service configuration on every deployment. +-- When delegated roles are configured, the managed login owns no write +-- capabilities directly. Reconcile its SET ROLE allow-list on every deployment. SELECT format('REVOKE %I FROM %I', granted.rolname, '{{ .ReadWriteRole }}') FROM pg_auth_members membership JOIN pg_roles granted ON granted.oid = membership.roleid diff --git a/templates/deployment/kustomize/base/job.yaml.tmpl b/templates/deployment/kustomize/base/job.yaml.tmpl index 4cf2e39..0d7ec6b 100644 --- a/templates/deployment/kustomize/base/job.yaml.tmpl +++ b/templates/deployment/kustomize/base/job.yaml.tmpl @@ -1,7 +1,7 @@ apiVersion: batch/v1 kind: Job metadata: - name: {{ .Service.Name.DNSCase }} + name: {{ .Deployment.Parameters.BootstrapJobName }} namespace: {{ .Namespace }} labels: codefly.dev/bootstrap-service: {{ .Service.Name.DNSCase }} @@ -35,7 +35,7 @@ spec: - ALL seccompProfile: type: RuntimeDefault -{{- if not .GitOps }} +{{- if not .Restricted }} envFrom: - secretRef: name: secret-{{ .Service.Name.DNSCase }} diff --git a/templates/deployment/kustomize/base/kustomization.yaml.tmpl b/templates/deployment/kustomize/base/kustomization.yaml.tmpl index 2b7b55a..25fcf79 100644 --- a/templates/deployment/kustomize/base/kustomization.yaml.tmpl +++ b/templates/deployment/kustomize/base/kustomization.yaml.tmpl @@ -1,5 +1,5 @@ resources: -{{- if not .GitOps }} +{{- if not .Restricted }} - namespace.yaml {{- end }} # In-cluster postgres (local-mode). For prod where the database is diff --git a/templates/deployment/kustomize/base/namespace.yaml.tmpl b/templates/deployment/kustomize/base/namespace.yaml.tmpl index 6f6d220..9bb25a9 100644 --- a/templates/deployment/kustomize/base/namespace.yaml.tmpl +++ b/templates/deployment/kustomize/base/namespace.yaml.tmpl @@ -1,4 +1,4 @@ -{{- if not .GitOps }} +{{- if not .Restricted }} apiVersion: v1 kind: Namespace metadata: diff --git a/templates/deployment/kustomize/base/stateful-set.yaml.tmpl b/templates/deployment/kustomize/base/stateful-set.yaml.tmpl index 4e65df3..b9926d1 100644 --- a/templates/deployment/kustomize/base/stateful-set.yaml.tmpl +++ b/templates/deployment/kustomize/base/stateful-set.yaml.tmpl @@ -62,7 +62,7 @@ spec: env: - name: PGDATA value: /var/lib/postgresql/data/pgdata -{{- if not .GitOps }} +{{- if not .Restricted }} envFrom: - secretRef: name: secret-{{ .Service.Name.DNSCase }} diff --git a/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl b/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl index c7b147f..4007cdd 100644 --- a/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl +++ b/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl @@ -1,5 +1,5 @@ resources: - ../../base -{{- if not .GitOps }} +{{- if not .Restricted }} - secret.yaml {{- end }} diff --git a/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl b/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl index c4938fa..f0994e2 100644 --- a/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl +++ b/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl @@ -1,4 +1,4 @@ -{{- if not .GitOps }} +{{- if not .Restricted }} apiVersion: v1 kind: Secret metadata: