feat(auth): add local and OIDC management to tht

This commit is contained in:
2026-08-16 19:23:30 +02:00
parent d17ad0e95b
commit 9646ae09a0
15 changed files with 992 additions and 13 deletions
+9 -3
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
@@ -188,12 +189,17 @@ func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(directory, "operator.env")
if err := os.WriteFile(envFile, nil, 0o600); err != nil {
physicalDirectory, err := filepath.EvalSymlinks(directory)
if err != nil {
t.Fatal(err)
}
envFile := filepath.Join(physicalDirectory, "operator.env")
authDirectory := filepath.Join(physicalDirectory, "auth")
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\nTHT_AUTH_CONFIG_ROOT="+strconv.Quote(authDirectory)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
installationPath := filepath.Join(directory, "thothii-installation.yaml")
contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\n"
contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
+26 -1
View File
@@ -34,9 +34,14 @@ type descriptor struct {
ProjectDirectory string `yaml:"projectDirectory"`
EnvFile string `yaml:"envFile"`
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
Authentication authenticationDescriptor `yaml:"authentication"`
Overrides []string `yaml:"overrides"`
}
type authenticationDescriptor struct {
ConfigDirectory string `yaml:"configDirectory"`
}
type workspaceRepositoryDescriptor struct {
Remote string `yaml:"remote"`
Branch string `yaml:"branch"`
@@ -50,6 +55,11 @@ type WorkspaceRepository struct {
Access string
}
// Authentication is the non-secret filesystem location for the installation auth configuration.
type Authentication struct {
ConfigDirectory string
}
// Installation is a validated local Compose installation. It intentionally contains paths, not
// environment values or secret content.
type Installation struct {
@@ -58,6 +68,7 @@ type Installation struct {
ProjectDirectory string
EnvFile string
WorkspaceRepository WorkspaceRepository
Authentication Authentication
Overrides []string
}
@@ -99,6 +110,9 @@ func Load(path string) (Installation, error) {
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
return Installation{}, err
}
if err := safeio.ValidateCanonicalPath(raw.Authentication.ConfigDirectory); err != nil {
return Installation{}, errors.New("authentication.configDirectory must be an absolute canonical path")
}
installation := Installation{
Path: path,
@@ -110,7 +124,15 @@ func Load(path string) (Installation, error) {
Branch: raw.WorkspaceRepository.Branch,
Access: raw.WorkspaceRepository.Access,
},
Overrides: make([]string, 0, len(raw.Overrides)),
Authentication: Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory},
Overrides: make([]string, 0, len(raw.Overrides)),
}
values, err := installation.environmentValues()
if err != nil {
return Installation{}, errors.New("installation secret declarations could not be read")
}
if values["THT_AUTH_CONFIG_ROOT"] != installation.AuthenticationDirectory() {
return Installation{}, errors.New("authentication.configDirectory must match THT_AUTH_CONFIG_ROOT")
}
for _, override := range raw.Overrides {
if err := requireRegularFile(override, "override"); err != nil {
@@ -136,6 +158,9 @@ func Load(path string) (Installation, error) {
return installation, nil
}
// AuthenticationDirectory returns the descriptor-owned, non-secret authentication root.
func (i Installation) AuthenticationDirectory() string { return i.Authentication.ConfigDirectory }
var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`)
+44 -2
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
@@ -67,6 +68,7 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
}
remote := "git@gitea.example.org:clinical/workspaces.git"
environment := strings.Join([]string{
"THT_AUTH_CONFIG_ROOT=" + strconv.Quote(filepath.Join(filepath.Dir(envFile), "auth")),
"THT_WORKSPACE_GIT_REMOTE=" + remote,
"THT_WORKSPACE_GIT_BRANCH=main",
"THT_WORKSPACE_GIT_SSH_KEY_FILE=" + privateKey,
@@ -77,6 +79,7 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
}
contents := "profile: local\nprojectDirectory: " + projectDirectory +
"\nenvFile: " + envFile +
"\nauthentication:\n configDirectory: " + filepath.Join(filepath.Dir(envFile), "auth") +
"\nworkspaceRepository:\n remote: " + remote +
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
@@ -102,6 +105,7 @@ func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) {
}
contents := "profile: local\nprojectDirectory: " + projectDirectory +
"\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n"
contents = strings.Replace(contents, "\noverrides:", "\nauthentication:\n configDirectory: "+filepath.Join(filepath.Dir(envFile), "auth")+"\noverrides:", 1)
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
@@ -194,6 +198,7 @@ func TestPreservationPathsReturnsCanonicalBindRootsBackupsAndSecretFiles(t *test
}
wanted = append(wanted, secret)
lines = append(lines, "APP_TOKEN_FILE="+secret)
lines = append(lines, "THT_AUTH_CONFIG_ROOT="+strconv.Quote(filepath.Join(root, "auth")))
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
@@ -239,6 +244,42 @@ func TestLoadRejectsRelativeInstallationPaths(t *testing.T) {
}
}
func TestLoadRequiresCanonicalAuthenticationDirectoryMatchingEnvironment(t *testing.T) {
installationPath, _, envFile, _ := writeInstallation(t, "local")
authDirectory := filepath.Join(filepath.Dir(installationPath), "auth")
contents, err := os.ReadFile(installationPath)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(envFile, []byte("THT_AUTH_CONFIG_ROOT="+strconv.Quote(authDirectory)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
if got := installation.AuthenticationDirectory(); got != authDirectory {
t.Fatalf("AuthenticationDirectory() = %q, want %q", got, authDirectory)
}
for _, invalid := range []string{"relative/auth", authDirectory + "/../auth"} {
bad := strings.Replace(string(contents), authDirectory, invalid, 1)
if err := os.WriteFile(installationPath, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(installationPath); err == nil {
t.Fatalf("Load accepted unsafe auth directory %q", invalid)
}
}
}
func TestParseAuthenticationDirectoryEnvironment(t *testing.T) {
values, err := parseComposeDotenv([]byte("THT_AUTH_CONFIG_ROOT=\"/tmp/auth\"\n"))
if err != nil || values["THT_AUTH_CONFIG_ROOT"] != "/tmp/auth" {
t.Fatalf("values=%#v err=%v", values, err)
}
}
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
t.Helper()
@@ -262,7 +303,8 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
}
}
envFile := filepath.Join(root, "environment file.env")
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
authDirectory := filepath.Join(root, "auth")
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\nTHT_AUTH_CONFIG_ROOT="+strconv.Quote(authDirectory)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
override := filepath.Join(root, "extra override.yaml")
@@ -270,7 +312,7 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
t.Fatal(err)
}
installationPath := filepath.Join(root, "thothii-installation.yaml")
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\noverrides:\n - " + override + "\n"
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}