feat(auth): integrate authentication with installation lifecycle

This commit is contained in:
2026-08-17 20:21:42 +02:00
parent 0a2c667231
commit 9558eaa508
30 changed files with 756 additions and 120 deletions
+20
View File
@@ -364,6 +364,26 @@ func parseSetupArgs(args []string) (setup.Request, error) {
target = &request.Answers.GitSSHKeyFile
case "--git-known-hosts-file":
target = &request.Answers.GitKnownHostsFile
case "--auth-mode":
target = &request.Answers.AuthMode
case "--auth-public-url":
target = &request.Answers.AuthPublicURL
case "--auth-admin-user":
target = &request.Answers.AuthAdminUser
case "--auth-admin-display-name":
target = &request.Answers.AuthAdminDisplayName
case "--auth-password-file":
target = &request.Answers.AuthPasswordFile
case "--auth-issuer":
target = &request.Answers.AuthIssuer
case "--auth-client-id":
target = &request.Answers.AuthClientID
case "--auth-authentik-base-url":
target = &request.Answers.AuthAuthentikBaseURL
case "--auth-user-group":
target = &request.Answers.AuthUserGroup
case "--auth-admin-group":
target = &request.Answers.AuthAdminGroup
default:
return setup.Request{}, fmt.Errorf("unknown setup option %q", flag)
}
+17 -1
View File
@@ -43,6 +43,22 @@ func TestBackupCommandParsesSafeTransactionalOptions(t *testing.T) {
}
}
func TestSetupArgumentsRequireCompleteNonInteractiveAuthenticationInputs(t *testing.T) {
request, err := parseSetupArgs([]string{
"--non-interactive", "--auth-mode", "local", "--auth-public-url", "http://127.0.0.1:8080",
"--auth-admin-user", "admin", "--auth-admin-display-name", "Initial Admin", "--auth-password-file", "/protected/password",
})
if err != nil {
t.Fatal(err)
}
if request.Answers.AuthMode != "local" || request.Answers.AuthPasswordFile != "/protected/password" {
t.Fatalf("setup request = %#v", request)
}
if _, err := parseSetupArgs([]string{"--auth-mode", "local", "--auth-mode", "oidc"}); err == nil {
t.Fatal("duplicate --auth-mode was accepted")
}
}
func TestBackupCommandDispatchesWithoutDockerAndPrintsCustodyWarning(t *testing.T) {
installation := config.Installation{Path: "/tmp/thothii-installation.yaml"}
var received backup.CreateRequest
@@ -1469,7 +1485,7 @@ case " $* " in
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
printf '%s\n' "$THT_FAKE_CONFIG"
else
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
fi ;;
*" run --rm --no-deps --no-TTY session-migrate "*)
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
+83 -5
View File
@@ -21,7 +21,9 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/service"
"gopkg.in/yaml.v3"
)
var (
@@ -130,6 +132,10 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
if err != nil {
return Result{}, fmt.Errorf("installation external secret references could not be read: %w", err)
}
authenticationPaths, err := authenticationConfigFiles(installation)
if err != nil {
return Result{}, err
}
revision, err := dependencies.revision(ctx, installation.ProjectDirectory)
if err != nil {
return Result{}, err
@@ -208,12 +214,12 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
InstallationID: installationID,
CreatedAt: dependencies.now().UTC(),
SourceRevision: revision,
IncludesSecrets: request.IncludeSecrets && len(secretPaths) > 0,
IncludesSecrets: request.IncludeSecrets && len(secretPaths)+len(authenticationPaths) > 0,
ComposeProject: installation.ProjectName(),
Images: images,
Volumes: volumes,
}
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, manifest, volumes, dependencies); err != nil {
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, authenticationPaths, manifest, volumes, dependencies); err != nil {
return Result{}, err
}
published = true
@@ -228,8 +234,8 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
maintenanceActive = false
}
result = Result{Path: output}
if request.IncludeSecrets {
result.Warning = "The archive contains external secret files. Protect its custody and access."
if manifest.IncludesSecrets {
result.Warning = "The archive contains external secret files, including authentication configuration. Protect its custody and access."
}
return result, nil
}
@@ -631,7 +637,7 @@ func runCompose(ctx context.Context, installation config.Installation, runner ar
return nil
}
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths, authenticationPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
directory := filepath.Dir(output)
temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp")
if err != nil {
@@ -732,6 +738,23 @@ func writeArchive(ctx context.Context, output string, reservation *archiveReserv
SourcePath: source, SHA256: "sha256:" + hex.EncodeToString(hash.Sum(nil)), Size: size, Sensitive: true,
})
}
for index, source := range authenticationPaths {
name := fmt.Sprintf("authentication-secrets/%03d-%s", index, filepath.Base(source))
if request.IncludeSecrets {
if err := addFile(name, "authentication-configuration", true, source); err != nil {
return err
}
entry := &manifest.Entries[len(manifest.Entries)-1]
entry.Kind, entry.SourcePath, entry.Sensitive = EntryExternalSecret, source, true
continue
}
if filepath.Base(source) != "auth.yaml" {
continue
}
if err := addAuthenticationReference(&manifest, name, source); err != nil {
return err
}
}
for _, volume := range volumes {
headerName := "volumes/" + volume.LogicalName + ".tar"
header := &zip.FileHeader{Name: headerName, Method: zip.Deflate}
@@ -804,6 +827,51 @@ func configurationInputs(installation config.Installation) []configurationInput
return inputs
}
const maxAuthenticationConfigurationBytes = 1 << 20
func authenticationConfigFiles(installation config.Installation) ([]string, error) {
directory := installation.AuthenticationDirectory()
if directory == "" {
return nil, nil
}
if err := safeio.ValidatePrivateDirectory(directory); err != nil {
return nil, errors.New("authentication configuration directory is unavailable or unsafe")
}
authPath := filepath.Join(directory, "auth.yaml")
contents, err := safeio.ReadCanonicalRegular(authPath, maxAuthenticationConfigurationBytes)
if err != nil {
return nil, errors.New("authentication configuration is unavailable or unsafe")
}
var configuration struct {
Mode string `yaml:"mode"`
}
if err := yaml.Unmarshal(contents, &configuration); err != nil || (configuration.Mode != "local" && configuration.Mode != "oidc") {
return nil, errors.New("authentication configuration is unavailable or invalid")
}
paths := []string{authPath}
if configuration.Mode == "local" {
usersPath := filepath.Join(directory, "users.yaml")
if _, err := safeio.ReadCanonicalRegular(usersPath, maxAuthenticationConfigurationBytes); err != nil {
return nil, errors.New("authentication user registry is unavailable or unsafe")
}
paths = append(paths, usersPath)
}
return paths, nil
}
func addAuthenticationReference(manifest *Manifest, name, source string) error {
contents, err := safeio.ReadCanonicalRegular(source, maxAuthenticationConfigurationBytes)
if err != nil {
return errors.New("authentication configuration is unavailable or unsafe")
}
digest := sha256.Sum256(contents)
manifest.Entries = append(manifest.Entries, Entry{
Path: name, Kind: EntrySecretReference, Owner: "authentication-configuration", SourcePath: source,
SHA256: "sha256:" + hex.EncodeToString(digest[:]), Size: int64(len(contents)), Sensitive: true,
})
return nil
}
func volumeArchiveCommand(volume string) []string {
return []string{"run", "--rm", "--network", "none", "--mount", "type=volume,src=" + volume + ",dst=/source,readonly", helperImage, "tar", "--numeric-owner", "-C", "/source", "-cf", "-", "."}
}
@@ -837,6 +905,10 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st
if err != nil || !info.IsDir() {
return errors.New("server preservation root is unavailable")
}
authStateRoot := ""
if variable == "THT_DATA_ROOT" {
authStateRoot = filepath.Join(root, "auth")
}
err = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
@@ -847,6 +919,12 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st
}
return nil
}
if authStateRoot != "" && path == authStateRoot {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("server preservation root contains a symlink")
}
+69
View File
@@ -70,6 +70,75 @@ func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *tes
}
}
func TestCreateReferencesAuthFilesByDefaultAndArchivesThemOnlyWithSecretCustody(t *testing.T) {
fixture := newBackupFixture(t, "local")
authDirectory := filepath.Join(filepath.Dir(fixture.installation.Path), "auth")
if err := os.Mkdir(authDirectory, 0o700); err != nil {
t.Fatal(err)
}
authPath := filepath.Join(authDirectory, "auth.yaml")
usersPath := filepath.Join(authDirectory, "users.yaml")
if err := os.WriteFile(authPath, []byte("version: 1\nmode: local\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(usersPath, []byte("users:\n - passwordHash: must-not-be-archived-by-default\n"), 0o600); err != nil {
t.Fatal(err)
}
fixture.installation.Authentication.ConfigDirectory = authDirectory
defaultOutput := filepath.Join(t.TempDir(), "default.zip")
defaultResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: defaultOutput}, testDependencies(t, newBackupRunner(fixture.installation, false)))
if err != nil {
t.Fatal(err)
}
if defaultResult.Warning != "" {
t.Fatalf("default backup warning = %q, want no custody warning", defaultResult.Warning)
}
defaultArchive := readFixtureArchive(t, defaultOutput)
defaultBytes := bytes.Join(mapValues(defaultArchive.files), nil)
for _, value := range []string{"mode: local", "must-not-be-archived-by-default"} {
if bytes.Contains(defaultBytes, []byte(value)) {
t.Fatalf("default backup contains authentication content %q", value)
}
}
if !manifestHasReference(defaultArchive.manifest, authPath) || manifestHasReference(defaultArchive.manifest, usersPath) {
t.Fatalf("default backup did not record only the auth.yaml configuration path: %#v", defaultArchive.manifest.Entries)
}
secretOutput := filepath.Join(t.TempDir(), "with-auth-secrets.zip")
secretResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: secretOutput, IncludeSecrets: true, Confirm: true}, testDependencies(t, newBackupRunner(fixture.installation, false)))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(secretResult.Warning, "custody") {
t.Fatalf("secret backup warning = %q, want custody guidance", secretResult.Warning)
}
secretArchive := readFixtureArchive(t, secretOutput)
for _, path := range []string{authPath, usersPath} {
if !manifestHasArchivedSecret(secretArchive.manifest, path) {
t.Fatalf("secret backup did not archive authentication file %q", path)
}
}
}
func manifestHasReference(manifest Manifest, sourcePath string) bool {
for _, entry := range manifest.Entries {
if entry.Kind == EntrySecretReference && entry.SourcePath == sourcePath && !entry.Archived {
return true
}
}
return false
}
func manifestHasArchivedSecret(manifest Manifest, sourcePath string) bool {
for _, entry := range manifest.Entries {
if entry.Kind == EntryExternalSecret && entry.SourcePath == sourcePath && entry.Archived && entry.Sensitive {
return true
}
}
return false
}
func TestCreateRestartsAndVerifiesAnInstallationThatWasRunning(t *testing.T) {
fixture := newBackupFixture(t, "local")
runner := newBackupRunner(fixture.installation, true)
+99 -32
View File
@@ -27,18 +27,19 @@ type RestoreResult struct {
Verified bool
}
type restoreLock interface { Release() error }
type restoreLock interface{ Release() error }
type restoreVerify func(context.Context, config.Installation, archiveRunner) error
type restoreDependencies struct {
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
acquireLock func(config.Installation) (restoreLock, error)
runner archiveRunner
sleep func(duration time.Duration)
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
verify map[string]restoreVerify
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
acquireLock func(config.Installation) (restoreLock, error)
runner archiveRunner
sleep func(duration time.Duration)
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
resetAuthenticationState func(context.Context, config.Installation, archiveRunner) error
verify map[string]restoreVerify
}
// Restore runs the host transaction. Concrete host dependencies are intentionally kept outside
@@ -48,60 +49,126 @@ func Restore(ctx context.Context, installation config.Installation, request Rest
}
func restoreWithDependencies(ctx context.Context, installation config.Installation, request RestoreRequest, deps restoreDependencies) (result RestoreResult, resultErr error) {
if !request.Confirm { return RestoreResult{}, ErrRestoreConfirmationRequired }
if request.Archive == "" { return RestoreResult{}, errors.New("restore archive is required") }
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.verify == nil {
if !request.Confirm {
return RestoreResult{}, ErrRestoreConfirmationRequired
}
if request.Archive == "" {
return RestoreResult{}, errors.New("restore archive is required")
}
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
return RestoreResult{}, errors.New("restore dependencies are incomplete")
}
preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true})
if err != nil { return RestoreResult{}, err }
if err != nil {
return RestoreResult{}, err
}
defer preflight.CloseArchive()
archive, err := preflight.RevalidateArchive()
if err != nil { return RestoreResult{}, err }
if err != nil {
return RestoreResult{}, err
}
checkpoint, err := deps.checkpoint(ctx, installation, CreateRequest{})
if err != nil { return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err) }
if err != nil {
return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err)
}
result.Checkpoint = checkpoint.Path
lock, err := deps.acquireLock(installation)
if err != nil { return result, err }
defer func() { if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil { resultErr = releaseErr } }()
if err != nil {
return result, err
}
defer func() {
if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil {
resultErr = releaseErr
}
}()
wasRunning, err := installationRunning(ctx, installation, deps.runner)
if err != nil { return result, err }
if err != nil {
return result, err
}
mutated := false
defer func() {
if resultErr != nil && mutated { _ = runCompose(context.Background(), installation, deps.runner, "stop") }
if resultErr != nil && mutated {
_ = runCompose(context.Background(), installation, deps.runner, "stop")
}
}()
if wasRunning {
if err := maintenance(ctx, installation, deps.runner, true); err != nil { return result, err }
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil { return result, err }
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil { return result, err }
if err := maintenance(ctx, installation, deps.runner, true); err != nil {
return result, err
}
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil {
return result, err
}
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil {
return result, err
}
}
reader, err := zip.NewReader(archive, preflight.ArchiveSize)
if err != nil { return result, fmt.Errorf("read verified restore archive: %w", err) }
if err != nil {
return result, fmt.Errorf("read verified restore archive: %w", err)
}
members := make(map[string]*zip.File, len(reader.File))
for _, member := range reader.File { members[member.Name] = member }
for _, member := range reader.File {
members[member.Name] = member
}
for _, entry := range preflight.Entries {
if entry.Kind == EntryVolume { continue }
if entry.Kind == EntryVolume {
continue
}
member := members[entry.Path]
if member == nil { return result, fmt.Errorf("verified archive is missing %q", entry.Path) }
if member == nil {
return result, fmt.Errorf("verified archive is missing %q", entry.Path)
}
stream, openErr := member.Open()
if openErr != nil { return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr) }
if openErr != nil {
return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr)
}
mutated = true
restoreErr := deps.restoreFile(ctx, installation, entry, stream)
closeErr := stream.Close()
if restoreErr != nil { return result, restoreErr }
if closeErr != nil { return result, closeErr }
if restoreErr != nil {
return result, restoreErr
}
if closeErr != nil {
return result, closeErr
}
}
if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil {
return result, fmt.Errorf("reset authentication state: %w", err)
}
if wasRunning {
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { return result, err }
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil {
return result, err
}
result.Restarted = true
}
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
check := deps.verify[name]
if check == nil { return result, fmt.Errorf("restore verification %q is unavailable", name) }
if err := check(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("restore verification %s: %w", name, err) }
if check == nil {
return result, fmt.Errorf("restore verification %q is unavailable", name)
}
if err := check(ctx, installation, deps.runner); err != nil {
return result, fmt.Errorf("restore verification %s: %w", name, err)
}
}
result.Verified = true
return result, nil
}
// resetAuthenticationState clears browser sessions and pending OIDC transactions without touching
// installation-global auth.yaml or users.yaml. The command runs as the unprivileged core user so
// the recreated state root is private to the service on both the local volume and server /data bind.
func resetAuthenticationState(ctx context.Context, installation config.Installation, runner archiveRunner) error {
result, err := runner.Run(ctx, installation.ComposeArgs(
"run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu",
"rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc",
), nil)
if err != nil {
return dockerError("reset authentication state", result, err)
}
if result.ExitCode != 0 {
return dockerError("reset authentication state", result, errors.New("Compose returned a nonzero exit status"))
}
return nil
}
+68
View File
@@ -59,6 +59,58 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi
}
}
func TestRestoreResetsAuthenticationStateBeforeRestart(t *testing.T) {
installation := preflightTestInstallation(t)
archive := restoreArchive(t)
runner := newBackupRunner(installation, true)
deps := restoreTestDependencies(t, runner)
var events []string
deps.restoreFile = func(_ context.Context, _ config.Installation, entry ArchiveEntryMetadata, _ io.Reader) error {
events = append(events, "file:"+entry.Path)
return nil
}
deps.resetAuthenticationState = func(context.Context, config.Installation, archiveRunner) error {
events = append(events, "reset-auth-state")
return nil
}
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
name := name
deps.verify[name] = func(context.Context, config.Installation, archiveRunner) error {
events = append(events, name)
return nil
}
}
result, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps)
if err != nil {
t.Fatal(err)
}
if !result.Restarted || !result.Verified {
t.Fatalf("restore result = %#v", result)
}
if got, want := events, []string{"file:configuration/operator.env", "reset-auth-state", "health", "doctor", "pi", "workspace"}; !equalStrings(got, want) {
t.Fatalf("restore events = %v, want %v", got, want)
}
}
func TestResetAuthenticationStateCreatesOnlyPrivateEmptyStateDirectories(t *testing.T) {
installation := preflightTestInstallation(t)
runner := &authenticationStateResetRunner{}
if err := resetAuthenticationState(context.Background(), installation, runner); err != nil {
t.Fatal(err)
}
joined := strings.Join(runner.args, "\x00")
for _, required := range []string{
"run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu",
"rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc",
} {
if !strings.Contains(joined, required) {
t.Fatalf("authentication state reset command omits %q: %#v", required, runner.args)
}
}
}
func TestRestorePreflightFailureDoesNotMutateTarget(t *testing.T) {
installation := preflightTestInstallation(t)
runner := newBackupRunner(installation, true)
@@ -213,6 +265,19 @@ type fakeRestoreLock struct {
release func()
}
type authenticationStateResetRunner struct{ args []string }
func (runner *authenticationStateResetRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
runner.args = append([]string(nil), args...)
return compose.Result{}, nil
}
func (runner *authenticationStateResetRunner) Stream(context.Context, []string, io.Reader, io.Writer) (compose.Result, error) {
return compose.Result{}, errors.New("authentication state reset must not stream a volume archive")
}
func (*authenticationStateResetRunner) SessionInventoryScope() string { return "mine" }
func (lock fakeRestoreLock) Release() error {
if lock.release != nil {
lock.release()
@@ -248,6 +313,9 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error {
return nil
},
resetAuthenticationState: func(context.Context, config.Installation, archiveRunner) error {
return nil
},
verify: map[string]restoreVerify{
"health": func(context.Context, config.Installation, archiveRunner) error { return nil },
"doctor": func(context.Context, config.Installation, archiveRunner) error { return nil },
+2 -2
View File
@@ -353,7 +353,7 @@ func filePermissions(installation config.Installation) error {
return nil
}
// ValidateVolumes checks the seven persistent volumes required by a ThothII installation.
// ValidateVolumes checks the eight persistent volumes required by a local ThothII installation.
func ValidateVolumes(rendered string) error {
var document struct {
Volumes map[string]json.RawMessage `json:"volumes"`
@@ -361,7 +361,7 @@ func ValidateVolumes(rendered string) error {
if err := json.Unmarshal([]byte(rendered), &document); err != nil {
return errors.New("Compose returned invalid rendered configuration")
}
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} {
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"} {
if _, exists := document.Volumes[name]; !exists {
return fmt.Errorf("rendered Compose configuration is missing required volume %s", name)
}
+12 -1
View File
@@ -27,6 +27,17 @@ func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing.
}
}
func TestValidateVolumesRequiresAuthState(t *testing.T) {
legacy := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}}}`
if err := ValidateVolumes(legacy); err == nil || !strings.Contains(err.Error(), "auth-state") {
t.Fatalf("ValidateVolumes() error = %v, want missing auth-state", err)
}
withAuthState := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}}}`
if err := ValidateVolumes(withAuthState); err != nil {
t.Fatalf("ValidateVolumes() error = %v, want complete volume set", err)
}
}
// Catches Docker availability short-circuiting a host file-permission failure.
func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) {
installation := doctorInstallation(t, "")
@@ -327,7 +338,7 @@ func assertChecklist(t *testing.T, report Report, want []string) {
}
}
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
const healthyServices = `[
{"Service":"core","State":"running","Health":"healthy"},
+3
View File
@@ -65,6 +65,9 @@ func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testi
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(environment), "THT_AUTH_CONFIG_ROOT=") {
t.Fatalf("generated environment does not declare the authentication config root: %s", environment)
}
for _, secretValue := range []string{"super-secret-value", "pi-secret-value", "private-key-value"} {
if bytes.Contains(descriptor, []byte(secretValue)) || bytes.Contains(environment, []byte(secretValue)) {
t.Fatalf("generated configuration contains a secret value %q", secretValue)
+10
View File
@@ -26,6 +26,16 @@ type Answers struct {
GitCAFile string
GitSSHKeyFile string
GitKnownHostsFile string
AuthMode string
AuthPublicURL string
AuthAdminUser string
AuthAdminDisplayName string
AuthPasswordFile string
AuthIssuer string
AuthClientID string
AuthAuthentikBaseURL string
AuthUserGroup string
AuthAdminGroup string
CreateSecretTemplates bool
}
+74
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
@@ -53,6 +54,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
return Result{}, fmt.Errorf("setup generated configuration is invalid: %w", err)
}
result := Result{DescriptorPath: files.DescriptorPath, ProjectName: installation.ProjectName(), Configured: true}
if err := configureAuthentication(ctx, installation, request, input, output); err != nil {
return Result{}, err
}
if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil {
return Result{}, fmt.Errorf("setup Compose configuration: %w", err)
}
@@ -78,6 +82,76 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
return result, nil
}
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
}
if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) {
return errors.New("setup authentication configuration is invalid")
}
args, err := authenticationConfigureArgs(request)
if err != nil {
return err
}
if exitCode := authconfig.Run(ctx, installation, args, input, io.Discard, output); exitCode != 0 {
return errors.New("setup authentication configuration failed")
}
if _, _, err := authconfig.Load(directory); err != nil {
return errors.New("setup authentication configuration is invalid")
}
return nil
}
func authenticationConfigureArgs(request Request) ([]string, error) {
answers := request.Answers
mode := answers.AuthMode
if mode == "" && !request.NonInteractive {
mode = "local"
}
if mode != "local" && mode != "oidc" {
return nil, errors.New("setup requires --auth-mode local or oidc")
}
if mode == "local" && request.NonInteractive && (answers.AuthAdminUser == "" || answers.AuthAdminDisplayName == "" || answers.AuthPasswordFile == "") {
return nil, errors.New("non-interactive local authentication requires --auth-admin-user, --auth-admin-display-name, and --auth-password-file")
}
publicURL := answers.AuthPublicURL
if publicURL == "" && !request.NonInteractive && mode == "local" {
publicURL = "http://127.0.0.1:8080"
}
if publicURL == "" {
return nil, errors.New("setup requires --auth-public-url")
}
args := []string{"configure", "--mode", mode, "--public-url", publicURL}
if mode == "local" {
if answers.AuthAdminUser != "" {
args = append(args, "--admin-user", answers.AuthAdminUser)
}
if answers.AuthAdminDisplayName != "" {
args = append(args, "--admin-display-name", answers.AuthAdminDisplayName)
}
if answers.AuthPasswordFile != "" {
args = append(args, "--password-file", answers.AuthPasswordFile)
}
return args, nil
}
for _, option := range []struct {
name, value string
}{
{"--issuer", answers.AuthIssuer},
{"--client-id", answers.AuthClientID},
{"--authentik-base-url", answers.AuthAuthentikBaseURL},
{"--user-group", answers.AuthUserGroup},
{"--admin-group", answers.AuthAdminGroup},
} {
if option.value == "" {
return nil, errors.New("OIDC authentication requires complete provider and group options")
}
args = append(args, option.name, option.value)
}
return args, nil
}
func checkHost(ctx context.Context, runner compose.Runner, root string) error {
checks := []struct {
name string
+59 -2
View File
@@ -11,7 +11,9 @@ import (
"testing"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
)
@@ -65,6 +67,55 @@ func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) {
}
}
func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *testing.T) {
projectRoot, request := setupRunFixture(t, true)
passwordFile := filepath.Join(projectRoot, "initial-admin-password")
if err := os.WriteFile(passwordFile, []byte("correct horse battery staple"), 0o600); err != nil {
t.Fatal(err)
}
request.NonInteractive = true
request.Answers.AuthMode = "local"
request.Answers.AuthPublicURL = "http://127.0.0.1:8080"
request.Answers.AuthAdminUser = "admin"
request.Answers.AuthAdminDisplayName = "Initial Admin"
request.Answers.AuthPasswordFile = passwordFile
runner := &setupRunner{}
if _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard); err != nil {
t.Fatal(err)
}
installationPath := filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")
installation, err := config.Load(installationPath)
if err != nil {
t.Fatal(err)
}
configuration, registry, err := authconfig.Load(installation.AuthenticationDirectory())
if err != nil {
t.Fatalf("setup did not create a statically valid authentication configuration: %v", err)
}
if configuration.Mode != "local" || len(registry.Users) != 1 || registry.Users[0].Username != "admin" {
t.Fatalf("authentication configuration = %#v registry = %#v", configuration, registry)
}
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config")
}
func TestRunRejectsIncompleteNonInteractiveLocalAuthenticationBeforeComposeRender(t *testing.T) {
_, request := setupRunFixture(t, true)
request.NonInteractive = true
request.Answers.AuthMode = "local"
request.Answers.AuthPublicURL = ""
request.Answers.AuthAdminUser = ""
request.Answers.AuthAdminDisplayName = ""
request.Answers.AuthPasswordFile = ""
runner := &setupRunner{}
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
if err == nil || !strings.Contains(err.Error(), "non-interactive local authentication") {
t.Fatalf("Run() error = %v, want non-interactive local authentication guidance", err)
}
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture")
}
func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) {
projectRoot, request := setupRunFixture(t, false)
runner := &setupRunner{failureAt: "docker engine"}
@@ -137,7 +188,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
}
func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"}
all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"}
for _, missing := range all {
t.Run("missing "+missing, func(t *testing.T) {
volumes := make([]string, 0, len(all)-1)
@@ -301,7 +352,7 @@ func setupStage(args []string) (string, compose.Result) {
}
}
const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
func renderedConfigForVolumes(volumes ...string) string {
entries := make([]string, 0, len(volumes))
@@ -338,12 +389,18 @@ func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) {
if err := os.MkdirAll(secrets, 0o700); err != nil {
t.Fatal(err)
}
passwordFile := filepath.Join(secrets, "initial-admin-password")
if err := os.WriteFile(passwordFile, []byte("fixture authentication password"), 0o600); err != nil {
t.Fatal(err)
}
return root, Request{
ProjectRoot: root, InstallationID: "ci", Profile: "local", ConfigureOnly: configureOnly, NonInteractive: true,
Answers: Answers{
WorkspaceRemote: "https://git.example.invalid/thothii-workspaces.git", WorkspaceBranch: "main", WorkspaceAccess: "https",
SecretsFile: filepath.Join(secrets, "thothii.secrets"), PiAuthFile: filepath.Join(secrets, "pi-auth.json"),
GitCredentialsFile: filepath.Join(secrets, "git-credentials"), GitCAFile: filepath.Join(secrets, "git-ca.pem"),
AuthMode: "local", AuthPublicURL: "http://127.0.0.1:8080", AuthAdminUser: "admin",
AuthAdminDisplayName: "Initial Admin", AuthPasswordFile: passwordFile,
CreateSecretTemplates: true,
},
}