feat(server): activate projected authentication safely

This commit is contained in:
User
2026-08-22 01:01:36 +02:00
parent 903c0b4de5
commit 3d9a9f0675
32 changed files with 1837 additions and 38 deletions
+30 -2
View File
@@ -30,6 +30,10 @@ var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
// file and leaves no final target until all content is synced.
var atomicWriteNewFile = writeNewFileAtomically
// effectiveUID is a package-private seam so projected server setup can prove its root gate
// happens before any filesystem mutation.
var effectiveUID = currentEffectiveUID
type answers struct {
installationID, profile string
workspaceRemote, workspaceBranch string
@@ -51,7 +55,12 @@ type generatedDescriptor struct {
Access string `yaml:"access"`
} `yaml:"workspaceRepository"`
Authentication struct {
ConfigDirectory string `yaml:"configDirectory"`
ConfigDirectory string `yaml:"configDirectory"`
RuntimeProjection *struct {
Directory string `yaml:"directory"`
UID uint32 `yaml:"uid"`
GID uint32 `yaml:"gid"`
} `yaml:"runtimeProjection,omitempty"`
} `yaml:"authentication"`
Overrides []string `yaml:"overrides"`
}
@@ -70,6 +79,9 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
if err := validateAnswers(values); err != nil {
return FilesResult{}, err
}
if values.profile == "server" && effectiveUID() != 0 {
return FilesResult{}, errors.New("projected server setup requires root")
}
directory, err := installationDirectory(root, values.installationID)
if err != nil {
@@ -77,7 +89,11 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
}
descriptorPath := filepath.Join(directory, descriptorName)
environmentPath := filepath.Join(directory, environmentName)
if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil {
if values.profile == "server" {
if err := ensureProjectedAuthDirectories(filepath.Join(directory, "auth"), filepath.Join(directory, "auth-runtime")); err != nil {
return FilesResult{}, errors.New("projected authentication directories are unavailable or unsafe")
}
} else if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil {
return FilesResult{}, errors.New("authentication directory is unavailable or unsafe")
}
result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath}
@@ -305,6 +321,17 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)}
descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess
descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth")
if value.profile == "server" {
descriptor.Authentication.RuntimeProjection = &struct {
Directory string `yaml:"directory"`
UID uint32 `yaml:"uid"`
GID uint32 `yaml:"gid"`
}{
Directory: filepath.Join(filepath.Dir(descriptorPath), "auth-runtime"),
UID: 10001,
GID: 10001,
}
}
descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")}
descriptorBytes, err := yaml.Marshal(descriptor)
if err != nil {
@@ -334,6 +361,7 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
if value.profile == "server" {
installationDirectory := filepath.Dir(descriptorPath)
lines = append(lines,
"THT_AUTH_RUNTIME_ROOT="+dotenvValue(descriptor.Authentication.RuntimeProjection.Directory),
"THT_DATA_ROOT="+dotenvValue(filepath.Join(installationDirectory, "data")),
"THT_PI_STATE_ROOT="+dotenvValue(filepath.Join(installationDirectory, "pi-state")),
"THT_WORKSPACE_REGISTRY_ROOT="+dotenvValue(filepath.Join(installationDirectory, "workspace-registry")),
+74
View File
@@ -0,0 +1,74 @@
//go:build linux
package setup
import (
"errors"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
func currentEffectiveUID() int { return os.Geteuid() }
func ensureProjectedAuthDirectories(canonicalRoot, runtimeRoot string) error {
parent := filepath.Dir(canonicalRoot)
canonicalName, runtimeName := filepath.Base(canonicalRoot), filepath.Base(runtimeRoot)
if parent != filepath.Dir(runtimeRoot) || canonicalName == runtimeName || canonicalName == "." || runtimeName == "." {
return errors.New("projected authentication directory layout is invalid")
}
parentFD, err := unix.Open(parent, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return errors.New("projected authentication parent is unavailable")
}
defer unix.Close(parentFD)
if err := requireProjectedDirectoryMetadata(parentFD, 0, 0); err != nil {
return err
}
if err := ensureProjectedAuthDirectoryAt(parentFD, canonicalName, 0, 0); err != nil {
return err
}
if err := ensureProjectedAuthDirectoryAt(parentFD, runtimeName, 10001, 10001); err != nil {
return err
}
if err := unix.Fsync(parentFD); err != nil {
return errors.New("projected authentication parent could not be synchronized")
}
return nil
}
func ensureProjectedAuthDirectoryAt(parentFD int, name string, uid, gid int) error {
fd, err := unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil && !errors.Is(err, unix.ENOENT) {
return errors.New("projected authentication directory is unavailable")
}
if errors.Is(err, unix.ENOENT) {
if err := unix.Mkdirat(parentFD, name, 0o700); err != nil && !errors.Is(err, unix.EEXIST) {
return errors.New("projected authentication directory could not be created")
}
fd, err = unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return errors.New("projected authentication directory is unavailable")
}
}
defer unix.Close(fd)
if err := unix.Fchown(fd, uid, gid); err != nil {
return errors.New("projected authentication directory ownership could not be set")
}
if err := unix.Fchmod(fd, 0o700); err != nil {
return errors.New("projected authentication directory mode could not be set")
}
if err := unix.Fsync(fd); err != nil {
return errors.New("projected authentication directory could not be synchronized")
}
return requireProjectedDirectoryMetadata(fd, uint32(uid), uint32(gid))
}
func requireProjectedDirectoryMetadata(fd int, uid, gid uint32) error {
var metadata unix.Stat_t
if err := unix.Fstat(fd, &metadata); err != nil || metadata.Mode&unix.S_IFMT != unix.S_IFDIR || metadata.Mode&0o777 != 0o700 || metadata.Uid != uid || metadata.Gid != gid {
return errors.New("projected authentication directory metadata is invalid")
}
return nil
}
@@ -0,0 +1,56 @@
//go:build linux
package setup
import (
"os"
"path/filepath"
"syscall"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
func TestEnsureFilesProjectedServerCreatesExactNumericAuthenticationRoots(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires root to verify numeric projected ownership")
}
root := newProject(t, "projected server root")
for _, name := range []string{"compose.server.yaml", "compose.auth-runtime-projection.yaml"} {
if err := os.WriteFile(filepath.Join(root, "deploy", name), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
}
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
result, err := EnsureFiles(Request{
ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true,
}, nil, ioDiscard{})
if err != nil {
t.Fatal(err)
}
installation, err := config.Load(result.DescriptorPath)
if err != nil {
t.Fatal(err)
}
projection := installation.RuntimeAuthProjection()
if projection == nil {
t.Fatal("generated server descriptor lacks runtime auth projection")
}
assertNumericDirectoryMetadata(t, installation.AuthenticationDirectory(), 0, 0, 0o700)
assertNumericDirectoryMetadata(t, projection.Directory, 10001, 10001, 0o700)
}
func assertNumericDirectoryMetadata(t *testing.T, path string, uid, gid uint32, mode os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
metadata, ok := info.Sys().(*syscall.Stat_t)
if !ok {
t.Fatalf("stat metadata for %s = %T", path, info.Sys())
}
if metadata.Uid != uid || metadata.Gid != gid || info.Mode().Perm() != mode {
t.Fatalf("metadata for %s = uid=%d gid=%d mode=%o, want uid=%d gid=%d mode=%o", path, metadata.Uid, metadata.Gid, info.Mode().Perm(), uid, gid, mode)
}
}
+18
View File
@@ -225,6 +225,24 @@ func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
}
}
func TestEnsureFilesProjectedServerRefusesBeforeAnyWriteWhenNotRoot(t *testing.T) {
root := newProject(t, "projected server non-root")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
previous := effectiveUID
effectiveUID = func() int { return 1000 }
t.Cleanup(func() { effectiveUID = previous })
_, err := EnsureFiles(Request{
ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true,
}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "root") {
t.Fatalf("EnsureFiles() error = %v, want root refusal", err)
}
if _, statErr := os.Lstat(filepath.Join(root, "deploy", "server")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("projected server path was created before root refusal: %v", statErr)
}
}
func TestEnsureFilesRejectsUnsafeServiceEndpointsBeforeWritingConfiguration(t *testing.T) {
for _, test := range []struct {
name, environment, value string
@@ -0,0 +1,11 @@
//go:build !linux
package setup
import "errors"
func currentEffectiveUID() int { return -1 }
func ensureProjectedAuthDirectories(_, _ string) error {
return errors.New("runtime authentication projection setup is unsupported")
}
+23 -1
View File
@@ -19,6 +19,9 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/service"
)
var publishProjectedCanonical = authconfig.PublishProjectedCanonical
var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady
// Result records the completed setup phases. DescriptorPath always identifies the descriptor
// selected by this invocation, including an idempotent rerun.
type Result struct {
@@ -85,7 +88,7 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
func configureAuthentication(ctx context.Context, installation config.Installation, request Request, input io.Reader, output io.Writer) error {
directory := installation.AuthenticationDirectory()
if _, _, err := authconfig.Load(directory); err == nil {
return nil
return publishConfiguredAuthentication(ctx, installation)
}
if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) {
return errors.New("setup authentication configuration is invalid")
@@ -100,6 +103,25 @@ func configureAuthentication(ctx context.Context, installation config.Installati
if _, _, err := authconfig.Load(directory); err != nil {
return errors.New("setup authentication configuration is invalid")
}
return publishConfiguredAuthentication(ctx, installation)
}
func publishConfiguredAuthentication(ctx context.Context, installation config.Installation) error {
projection := installation.RuntimeAuthProjection()
if projection == nil {
return nil
}
status, err := publishProjectedCanonical(ctx, installation.AuthenticationDirectory(), authconfig.ProjectionSpec{
RuntimeRoot: projection.Directory,
UID: projection.UID,
GID: projection.GID,
})
if err != nil || status.State != "ready" || !status.Equal {
return errors.New("setup authentication runtime projection could not be published")
}
if err := requireRuntimeAuthProjectionReady(installation); err != nil {
return errors.New("setup authentication runtime projection could not be verified")
}
return nil
}
+97
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
@@ -99,6 +100,102 @@ func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *test
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config")
}
func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testing.T) {
projectRoot, request := setupRunFixture(t, true)
request.Profile = "server"
if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard); err != nil {
t.Fatal(err)
}
installation, err := config.Load(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml"))
if err != nil {
t.Fatal(err)
}
projection := installation.RuntimeAuthProjection()
if projection == nil {
t.Fatal("generated server installation has no runtime auth projection")
}
status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.Directory, UID: projection.UID, GID: projection.GID})
if err != nil || status.Selector.State != "ready" {
t.Fatalf("initial runtime auth projection = %#v, %v; want ready", status, err)
}
}
func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicationFails(t *testing.T) {
projectRoot, request := setupRunFixture(t, true)
request.Profile = "server"
if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
previous := publishProjectedCanonical
publishProjectedCanonical = func(ctx context.Context, canonicalRoot string, spec authconfig.ProjectionSpec) (authconfig.ProjectionStatus, error) {
transaction, err := authconfig.BeginExternalProjectionTransaction(ctx, canonicalRoot, spec)
if err != nil {
return authconfig.ProjectionStatus{}, err
}
if err := transaction.Close(); err != nil {
return authconfig.ProjectionStatus{}, err
}
return authconfig.ProjectionStatus{}, errors.New("synthetic-password-sentinel")
}
t.Cleanup(func() { publishProjectedCanonical = previous })
_, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard)
if err == nil || strings.Contains(err.Error(), "synthetic-password-sentinel") {
t.Fatalf("Run() error = %v, want sanitized publication failure", err)
}
installation, loadErr := config.Load(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml"))
if loadErr != nil {
t.Fatal(loadErr)
}
projection := installation.RuntimeAuthProjection()
if projection == nil {
t.Fatal("generated server installation has no runtime auth projection")
}
_, inspectErr := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.Directory, UID: projection.UID, GID: projection.GID})
if !errors.Is(inspectErr, authprojection.ErrBlocked) {
t.Fatalf("Inspect() error = %v, want blocked projection", inspectErr)
}
}
func TestRunConfigureOnlyVerifiesProjectedAuthenticationAfterPublication(t *testing.T) {
projectRoot, request := setupRunFixture(t, true)
request.Profile = "server"
if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
previous := requireRuntimeAuthProjectionReady
calls := 0
requireRuntimeAuthProjectionReady = func(installation config.Installation) error {
calls++
projection := installation.RuntimeAuthProjection()
if projection == nil {
t.Fatal("post-publication readiness received an unprojected installation")
}
status, err := authprojection.Inspect(authprojection.Spec{
RuntimeRoot: projection.Directory,
UID: projection.UID,
GID: projection.GID,
})
if err != nil || status.Selector.State != "ready" {
t.Fatalf("post-publication projection = %#v, %v; want ready", status, err)
}
return errors.New("synthetic-readiness-secret")
}
t.Cleanup(func() { requireRuntimeAuthProjectionReady = previous })
_, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard)
if err == nil || strings.Contains(err.Error(), "synthetic-readiness-secret") {
t.Fatalf("Run() error = %v, want sanitized post-publication readiness failure", err)
}
if calls != 1 {
t.Fatalf("post-publication readiness calls = %d, want 1", calls)
}
}
func TestRunRejectsIncompleteNonInteractiveLocalAuthenticationBeforeComposeRender(t *testing.T) {
_, request := setupRunFixture(t, true)
request.NonInteractive = true