feat(server): activate projected authentication safely
This commit is contained in:
@@ -39,7 +39,14 @@ type descriptor struct {
|
||||
}
|
||||
|
||||
type authenticationDescriptor struct {
|
||||
ConfigDirectory string `yaml:"configDirectory"`
|
||||
ConfigDirectory string `yaml:"configDirectory"`
|
||||
RuntimeProjection *runtimeProjectionDescriptor `yaml:"runtimeProjection"`
|
||||
}
|
||||
|
||||
type runtimeProjectionDescriptor struct {
|
||||
Directory string `yaml:"directory"`
|
||||
UID uint32 `yaml:"uid"`
|
||||
GID uint32 `yaml:"gid"`
|
||||
}
|
||||
|
||||
type workspaceRepositoryDescriptor struct {
|
||||
@@ -55,9 +62,17 @@ type WorkspaceRepository struct {
|
||||
Access string
|
||||
}
|
||||
|
||||
// RuntimeProjection is the non-secret runtime root and numeric container ownership contract.
|
||||
type RuntimeProjection struct {
|
||||
Directory string
|
||||
UID uint32
|
||||
GID uint32
|
||||
}
|
||||
|
||||
// Authentication is the non-secret filesystem location for the installation auth configuration.
|
||||
type Authentication struct {
|
||||
ConfigDirectory string
|
||||
ConfigDirectory string
|
||||
RuntimeProjection *RuntimeProjection
|
||||
}
|
||||
|
||||
// Installation is a validated local Compose installation. It intentionally contains paths, not
|
||||
@@ -114,6 +129,14 @@ func Load(path string) (Installation, error) {
|
||||
return Installation{}, errors.New("authentication.configDirectory must be an absolute canonical path")
|
||||
}
|
||||
|
||||
authentication := Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory}
|
||||
if raw.Authentication.RuntimeProjection != nil {
|
||||
authentication.RuntimeProjection = &RuntimeProjection{
|
||||
Directory: raw.Authentication.RuntimeProjection.Directory,
|
||||
UID: raw.Authentication.RuntimeProjection.UID,
|
||||
GID: raw.Authentication.RuntimeProjection.GID,
|
||||
}
|
||||
}
|
||||
installation := Installation{
|
||||
Path: path,
|
||||
Profile: raw.Profile,
|
||||
@@ -124,7 +147,7 @@ func Load(path string) (Installation, error) {
|
||||
Branch: raw.WorkspaceRepository.Branch,
|
||||
Access: raw.WorkspaceRepository.Access,
|
||||
},
|
||||
Authentication: Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory},
|
||||
Authentication: authentication,
|
||||
Overrides: make([]string, 0, len(raw.Overrides)),
|
||||
}
|
||||
values, err := installation.environmentValues()
|
||||
@@ -140,6 +163,14 @@ func Load(path string) (Installation, error) {
|
||||
}
|
||||
installation.Overrides = append(installation.Overrides, filepath.Clean(override))
|
||||
}
|
||||
if err := installation.validateRuntimeAuthProjection(values); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
if installation.HasRuntimeAuthProjection() {
|
||||
if err := requireRegularFile(installation.runtimeAuthProjectionComposePath(), "runtime authentication Compose override"); err != nil {
|
||||
return Installation{}, err
|
||||
}
|
||||
}
|
||||
for _, composeFile := range installation.ComposeFiles()[:2] {
|
||||
if err := requireRegularFile(composeFile, "Compose file"); err != nil {
|
||||
return Installation{}, err
|
||||
@@ -161,6 +192,49 @@ func Load(path string) (Installation, error) {
|
||||
// AuthenticationDirectory returns the descriptor-owned, non-secret authentication root.
|
||||
func (i Installation) AuthenticationDirectory() string { return i.Authentication.ConfigDirectory }
|
||||
|
||||
// RuntimeAuthProjection returns an independent descriptor copy when this installation uses the
|
||||
// Linux-only runtime auth publication contract.
|
||||
func (i Installation) RuntimeAuthProjection() *RuntimeProjection {
|
||||
if i.Authentication.RuntimeProjection == nil {
|
||||
return nil
|
||||
}
|
||||
projection := *i.Authentication.RuntimeProjection
|
||||
return &projection
|
||||
}
|
||||
|
||||
// HasRuntimeAuthProjection reports whether the descriptor selects the runtime auth projection.
|
||||
func (i Installation) HasRuntimeAuthProjection() bool {
|
||||
return i.Authentication.RuntimeProjection != nil
|
||||
}
|
||||
|
||||
func (i Installation) validateRuntimeAuthProjection(values map[string]string) error {
|
||||
projection := i.RuntimeAuthProjection()
|
||||
if projection == nil {
|
||||
if values["THT_AUTH_RUNTIME_ROOT"] != "" {
|
||||
return errors.New("THT_AUTH_RUNTIME_ROOT requires authentication.runtimeProjection")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if i.Profile != "server" {
|
||||
return errors.New("authentication.runtimeProjection requires the server profile")
|
||||
}
|
||||
if err := safeio.ValidateCanonicalPath(projection.Directory); err != nil || projection.Directory == i.AuthenticationDirectory() {
|
||||
return errors.New("authentication.runtimeProjection.directory must be a distinct absolute canonical path")
|
||||
}
|
||||
if projection.UID != 10001 || projection.GID != 10001 {
|
||||
return errors.New("authentication.runtimeProjection requires uid and gid 10001")
|
||||
}
|
||||
if values["THT_AUTH_RUNTIME_ROOT"] != projection.Directory {
|
||||
return errors.New("authentication.runtimeProjection.directory must match THT_AUTH_RUNTIME_ROOT")
|
||||
}
|
||||
for _, override := range i.Overrides {
|
||||
if filepath.Clean(override) == i.runtimeAuthProjectionComposePath() {
|
||||
return errors.New("runtime authentication Compose override is automatic and must not be declared")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
||||
var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`)
|
||||
|
||||
@@ -250,6 +324,9 @@ func (i Installation) ComposeFiles() []string {
|
||||
filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"),
|
||||
}
|
||||
files = append(files, i.Overrides...)
|
||||
if i.HasRuntimeAuthProjection() {
|
||||
files = append(files, i.runtimeAuthProjectionComposePath())
|
||||
}
|
||||
currentImage := i.CurrentImageOverridePath()
|
||||
if info, err := os.Lstat(currentImage); err == nil && info.Mode().IsRegular() {
|
||||
files = append(files, currentImage)
|
||||
@@ -257,6 +334,10 @@ func (i Installation) ComposeFiles() []string {
|
||||
return files
|
||||
}
|
||||
|
||||
func (i Installation) runtimeAuthProjectionComposePath() string {
|
||||
return filepath.Join(i.ProjectDirectory, "deploy", "compose.auth-runtime-projection.yaml")
|
||||
}
|
||||
|
||||
// ControlDirectory contains state that is private to one installation descriptor, even when
|
||||
// multiple installations intentionally share one source checkout.
|
||||
func (i Installation) ControlDirectory() string {
|
||||
|
||||
@@ -52,6 +52,104 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) {
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
|
||||
func TestLoadAcceptsServerRuntimeProjectionAndPlacesAutomaticOverrideBeforeCurrentImage(t *testing.T) {
|
||||
installationPath, projectDirectory, envFile, override := writeInstallation(t, "server")
|
||||
root := filepath.Dir(installationPath)
|
||||
authDirectory := filepath.Join(root, "canonical-auth")
|
||||
runtimeDirectory := filepath.Join(root, "runtime-auth")
|
||||
automaticOverride := filepath.Join(projectDirectory, "deploy", "compose.auth-runtime-projection.yaml")
|
||||
if err := os.WriteFile(automaticOverride, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate := Installation{Path: installationPath, ProjectDirectory: projectDirectory}
|
||||
currentImage := candidate.CurrentImageOverridePath()
|
||||
if err := os.MkdirAll(filepath.Dir(currentImage), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(currentImage, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRuntimeProjectionFixture(t, installationPath, envFile, "server", authDirectory, runtimeDirectory, runtimeDirectory, 10001, 10001, []string{override})
|
||||
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if !installation.HasRuntimeAuthProjection() {
|
||||
t.Fatal("HasRuntimeAuthProjection() = false, want true")
|
||||
}
|
||||
projection := installation.RuntimeAuthProjection()
|
||||
if projection == nil || projection.Directory != runtimeDirectory || projection.UID != 10001 || projection.GID != 10001 {
|
||||
t.Fatalf("RuntimeAuthProjection() = %#v", projection)
|
||||
}
|
||||
projection.Directory = "mutated"
|
||||
if got := installation.RuntimeAuthProjection(); got == nil || got.Directory != runtimeDirectory {
|
||||
t.Fatalf("RuntimeAuthProjection() did not return an independent copy: %#v", got)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
|
||||
override,
|
||||
automaticOverride,
|
||||
currentImage,
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidRuntimeProjection(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile string
|
||||
configDirectory func(root string) string
|
||||
runtimeDirectory func(root string) string
|
||||
environmentRoot func(root string) string
|
||||
uid, gid uint32
|
||||
manualOverride bool
|
||||
}{
|
||||
{name: "local profile", profile: "local", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10001},
|
||||
{name: "relative runtime directory", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(string) string { return "relative-runtime-auth" }, environmentRoot: func(string) string { return "relative-runtime-auth" }, uid: 10001, gid: 10001},
|
||||
{name: "noncanonical runtime directory", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return root + "/runtime-auth/../runtime-auth" }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10001},
|
||||
{name: "equal canonical and runtime directories", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "canonical-auth") }, uid: 10001, gid: 10001},
|
||||
{name: "uid mismatch", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10000, gid: 10001},
|
||||
{name: "gid mismatch", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10000},
|
||||
{name: "missing runtime environment", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return "" }, uid: 10001, gid: 10001},
|
||||
{name: "mismatched runtime environment", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "different-runtime-auth") }, uid: 10001, gid: 10001},
|
||||
{name: "manual automatic override", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10001, manualOverride: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installationPath, projectDirectory, envFile, override := writeInstallation(t, test.profile)
|
||||
root := filepath.Dir(installationPath)
|
||||
automaticOverride := filepath.Join(projectDirectory, "deploy", "compose.auth-runtime-projection.yaml")
|
||||
if err := os.WriteFile(automaticOverride, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides := []string{override}
|
||||
if test.manualOverride {
|
||||
overrides = append(overrides, automaticOverride)
|
||||
}
|
||||
writeRuntimeProjectionFixture(t, installationPath, envFile, test.profile, test.configDirectory(root), test.runtimeDirectory(root), test.environmentRoot(root), test.uid, test.gid, overrides)
|
||||
|
||||
if _, err := Load(installationPath); err == nil {
|
||||
t.Fatal("Load() unexpectedly accepted an invalid runtime authentication projection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsRuntimeProjectionEnvironmentWithoutDescriptor(t *testing.T) {
|
||||
installationPath, _, envFile, _ := writeInstallation(t, "server")
|
||||
authDirectory := filepath.Join(filepath.Dir(installationPath), "auth")
|
||||
runtimeDirectory := filepath.Join(filepath.Dir(installationPath), "runtime-auth")
|
||||
contents := "THT_AUTH_CONFIG_ROOT=" + strconv.Quote(authDirectory) + "\nTHT_AUTH_RUNTIME_ROOT=" + strconv.Quote(runtimeDirectory) + "\n"
|
||||
if err := os.WriteFile(envFile, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(installationPath); err == nil {
|
||||
t.Fatal("Load() unexpectedly accepted THT_AUTH_RUNTIME_ROOT without runtimeProjection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *testing.T) {
|
||||
installationPath, projectDirectory, envFile, _ := writeInstallation(t, "local")
|
||||
gitOverride := filepath.Join(projectDirectory, "deploy", "compose.git-ssh.yaml")
|
||||
@@ -280,6 +378,25 @@ func TestParseAuthenticationDirectoryEnvironment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeRuntimeProjectionFixture(t *testing.T, installationPath, envFile, profile, configDirectory, runtimeDirectory, environmentRoot string, uid, gid uint32, overrides []string) {
|
||||
t.Helper()
|
||||
lines := []string{"THT_AUTH_CONFIG_ROOT=" + strconv.Quote(configDirectory)}
|
||||
if environmentRoot != "" {
|
||||
lines = append(lines, "THT_AUTH_RUNTIME_ROOT="+strconv.Quote(environmentRoot))
|
||||
}
|
||||
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectDirectory := filepath.Join(filepath.Dir(installationPath), "project directory with spaces")
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n"
|
||||
for _, override := range overrides {
|
||||
contents += " - " + override + "\n"
|
||||
}
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user