feat(server): activate projected authentication safely
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user