feat(auth): integrate authentication with installation lifecycle
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user