diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index 8ccd5741..a0041124 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/backup" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" @@ -22,6 +23,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/output" "github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/project" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/serverops" "github.com/aritmolab/thothii/tools/tht/internal/service" "github.com/aritmolab/thothii/tools/tht/internal/setup" @@ -38,6 +40,10 @@ Commands: setup [--configure-only] [--installation-id ID] [--profile local|server] Create or validate the local non-secret installation configuration. version [--json] Show the host CLI build identity. + auth configure --mode local|oidc ... + Configure local users or OIDC group mapping; see tht auth for exact options. + auth status [--json] Show the redacted authentication configuration status. + auth user ... Manage local users; unavailable for OIDC installations. status Show the Compose service state. doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics. logs Show the latest 200 sanitized service log lines (bounded; no follow mode). @@ -130,6 +136,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil)) return 2 } + if command != "auth" || len(commandArgs) == 0 || commandArgs[0] != "configure" { + if err := safeio.ValidatePrivateDirectory(installation.AuthenticationDirectory()); err != nil { + fmt.Fprintln(stderr, "tht: authentication configuration directory is unavailable or unsafe") + return 2 + } + } secretFiles, err := installation.SecretFiles() if err != nil { fmt.Fprintln(stderr, "tht: installation secret declarations could not be read") @@ -144,6 +156,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { runner := compose.NewRunner("") var result compose.Result switch command { + case "auth": + return authconfig.Run(ctx, installation, commandArgs, os.Stdin, stdout, stderr) case "status": if len(commandArgs) != 0 { return commandUsageError(stderr, "status does not accept arguments") diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index ddba657b..a65803c2 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -201,7 +201,7 @@ func TestRootCommandIdentity(t *testing.T) { name: "help banner", args: []string{"--help"}, wantCode: 0, - wantText: "Usage: tht ", + wantText: " auth configure", }, { name: "version path", @@ -1446,8 +1446,12 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture { } } envFile := filepath.Join(root, "installation.env") + authDirectory := filepath.Join(root, "auth") + if err := os.Mkdir(authDirectory, 0o700); err != nil { + t.Fatal(err) + } installationPath := filepath.Join(root, "thothii-installation.yaml") - contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\n" + contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n" if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } @@ -1522,6 +1526,7 @@ func (f cliFixture) setEnvironment(t *testing.T, values ...string) { func (f cliFixture) setEnvContents(t *testing.T, env string) { t.Helper() + env += "THT_AUTH_CONFIG_ROOT=" + strconv.Quote(filepath.Join(f.root, "auth")) + "\n" if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil { t.Fatal(err) } @@ -1547,7 +1552,7 @@ func (f cliFixture) setProfile(t *testing.T, profile string) { if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil { t.Fatal(err) } - contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n" + contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n" if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil { t.Fatal(err) } diff --git a/tools/tht/go.mod b/tools/tht/go.mod index 36abe03a..2b7a18c1 100644 --- a/tools/tht/go.mod +++ b/tools/tht/go.mod @@ -13,6 +13,7 @@ require ( github.com/sirupsen/logrus v1.9.1 golang.org/x/crypto v0.55.0 golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 ) require ( diff --git a/tools/tht/go.sum b/tools/tht/go.sum index 73b086ab..00478aa4 100644 --- a/tools/tht/go.sum +++ b/tools/tht/go.sum @@ -31,6 +31,8 @@ golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xi golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/tools/tht/internal/authconfig/commands.go b/tools/tht/internal/authconfig/commands.go new file mode 100644 index 00000000..6a021d8c --- /dev/null +++ b/tools/tht/internal/authconfig/commands.go @@ -0,0 +1,628 @@ +package authconfig + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" + "golang.org/x/term" + "gopkg.in/yaml.v3" +) + +const maxPasswordFileBytes int64 = 1025 + +var errCommandRefused = errors.New("authentication command refused") + +// Run implements the host-only authentication operator surface. It accepts password bytes only +// from an echo-free terminal or a bounded private file, and never writes them to either stream. +func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + _ = ctx + if len(args) == 0 { + return authFailure(stderr, "auth requires a subcommand") + } + directory := installation.AuthenticationDirectory() + switch args[0] { + case "configure": + if err := configure(directory, args[1:], stdin, stderr); err != nil { + return authFailure(stderr, authMessage(err)) + } + return 0 + case "status": + if err := status(directory, args[1:], stdout); err != nil { + return authFailure(stderr, authMessage(err)) + } + return 0 + case "user": + if err := user(directory, args[1:], stdin, stdout, stderr); err != nil { + return authFailure(stderr, authMessage(err)) + } + return 0 + case "check": + return authFailure(stderr, "auth check is not available in this release") + default: + return authFailure(stderr, "unknown auth subcommand") + } +} + +func authFailure(stderr io.Writer, message string) int { + fmt.Fprintf(stderr, "tht: %s\n", message) + return 2 +} + +func authMessage(err error) string { + switch { + case errors.Is(err, errPasswordFileRequired): + return "--password-file is required when standard input is not an interactive terminal" + case errors.Is(err, errExistingConfiguration): + return "authentication is already configured; refusing to overwrite it" + case errors.Is(err, errOIDCUsers): + return "local user commands are unavailable while OIDC authentication is configured" + case errors.Is(err, errLastAdministrator): + return "refusing to remove or disable the last enabled administrator" + case errors.Is(err, errAmbiguousGroups): + return "OIDC user and administrator groups must be different" + case errors.Is(err, errCommandRefused): + return "authentication command refused" + default: + return "authentication configuration is unavailable or invalid" + } +} + +var ( + errPasswordFileRequired = errors.New("password file required") + errExistingConfiguration = errors.New("existing configuration") + errOIDCUsers = errors.New("OIDC user commands") + errLastAdministrator = errors.New("last administrator") + errAmbiguousGroups = errors.New("ambiguous groups") +) + +type configureRequest struct { + mode, publicURL, adminUser, adminDisplayName, passwordFile string + issuer, clientID, authentikBaseURL, userGroup, adminGroup string +} + +func configure(directory string, args []string, stdin io.Reader, stderr io.Writer) error { + request, err := parseConfigure(args) + if err != nil { + return errCommandRefused + } + if err := safeio.EnsurePrivateDirectory(directory); err != nil { + return errCommandRefused + } + if _, _, err := Load(directory); err == nil { + return errExistingConfiguration + } + for _, name := range []string{authFileName, usersFileName} { + if _, err := os.Lstat(filepath.Join(directory, name)); !errors.Is(err, os.ErrNotExist) { + return errCommandRefused + } + } + if request.mode == "oidc" { + if request.userGroup == request.adminGroup { + return errAmbiguousGroups + } + return writeInitialOIDC(directory, request) + } + if request.adminUser == "" { + if err := promptLocalConfigure(&request, stdin, stderr); err != nil { + return err + } + } + password, err := readPassword(request.passwordFile, stdin, stderr) + if err != nil { + return err + } + defer zero(password) + hash, err := HashPassword(password, nil) + if err != nil { + return errCommandRefused + } + initialUser, err := newUser(request.adminUser, request.adminDisplayName, hash, []Role{RoleAdmin}) + if err != nil { + return errCommandRefused + } + return writeInitialLocal(directory, request.publicURL, initialUser) +} + +func parseConfigure(args []string) (configureRequest, error) { + request := configureRequest{} + values := map[string]*string{ + "--mode": &request.mode, "--public-url": &request.publicURL, "--admin-user": &request.adminUser, + "--admin-display-name": &request.adminDisplayName, "--password-file": &request.passwordFile, + "--issuer": &request.issuer, "--client-id": &request.clientID, "--authentik-base-url": &request.authentikBaseURL, + "--user-group": &request.userGroup, "--admin-group": &request.adminGroup, + } + for len(args) > 0 { + option := args[0] + args = args[1:] + target, exists := values[option] + if !exists || len(args) == 0 || *target != "" { + return configureRequest{}, errCommandRefused + } + *target, args = args[0], args[1:] + } + if request.mode != "local" && request.mode != "oidc" || !validPublicURL(request.publicURL) { + return configureRequest{}, errCommandRefused + } + if request.mode == "local" { + if request.issuer != "" || request.clientID != "" || request.authentikBaseURL != "" || request.userGroup != "" || request.adminGroup != "" { + return configureRequest{}, errCommandRefused + } + if request.adminUser != "" && request.passwordFile == "" { // TTY supplies the password interactively. + return request, nil + } + return request, nil + } + if request.adminUser != "" || request.adminDisplayName != "" || request.passwordFile != "" || request.issuer == "" || request.clientID == "" || request.authentikBaseURL == "" || request.userGroup == "" || request.adminGroup == "" || !validOIDCURL(request.issuer) || !validHTTPSURL(request.authentikBaseURL) || (!isHTTPS(request.publicURL) && !isLoopbackURL(request.publicURL)) { + return configureRequest{}, errCommandRefused + } + return request, nil +} + +func promptLocalConfigure(request *configureRequest, stdin io.Reader, stderr io.Writer) error { + file, ok := stdin.(*os.File) + if !ok || !term.IsTerminal(int(file.Fd())) { + return errPasswordFileRequired + } + reader := bufio.NewReader(file) + username, err := promptLine(reader, stderr, "Initial administrator username: ") + if err != nil || username == "" { + return errCommandRefused + } + displayName, err := promptLine(reader, stderr, "Initial administrator display name (optional): ") + if err != nil { + return errCommandRefused + } + request.adminUser, request.adminDisplayName = username, displayName + return nil +} + +func promptLine(reader *bufio.Reader, stderr io.Writer, prompt string) (string, error) { + fmt.Fprint(stderr, prompt) + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + return strings.TrimSpace(line), nil +} + +func writeInitialLocal(directory, publicURL string, user User) error { + registryBytes, err := yaml.Marshal(Registry{Version: 1, Users: []User{user}}) + if err != nil { + return errCommandRefused + } + configBytes, err := yaml.Marshal(Config{Version: 1, Mode: "local", PublicURL: publicURL, Session: defaultSession(), Local: LocalConfig{UsersFile: usersFileName}}) + if err != nil { + return errCommandRefused + } + if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, usersFileName), append(registryBytes, '\n'), 0o600); err != nil { + return errCommandRefused + } + if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(configBytes, '\n'), 0o600); err != nil { + return errCommandRefused + } + return nil +} + +func writeInitialOIDC(directory string, request configureRequest) error { + configuration := Config{ + Version: 1, Mode: "oidc", PublicURL: request.publicURL, Session: defaultSession(), + OIDC: OIDCConfig{Issuer: request.issuer, ClientID: request.clientID, ClientSecretRef: "THT_OIDC_CLIENT_SECRET", Scopes: []string{"openid", "profile", "email"}, GroupsClaim: "groups"}, + GroupCatalog: GroupCatalogConfig{Driver: "authentik", BaseURL: request.authentikBaseURL, APITokenRef: "THT_AUTHENTIK_API_TOKEN"}, + Authorization: AuthorizationConfig{GroupRoles: map[string][]Role{request.userGroup: {RoleUser}, request.adminGroup: {RoleAdmin}}}, + } + contents, err := yaml.Marshal(configuration) + if err != nil { + return errCommandRefused + } + if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(contents, '\n'), 0o600); err != nil { + return errCommandRefused + } + return nil +} + +func defaultSession() SessionConfig { + return SessionConfig{RegularTTLSeconds: 43200, RegularIdleSeconds: 7200, RememberTTLSeconds: 2592000, RememberIdleSeconds: 604800, OIDCTTLSeconds: 28800} +} + +func readPassword(path string, stdin io.Reader, stderr io.Writer) ([]byte, error) { + if path != "" { + if err := safeio.ValidatePrivateRegular(path); err != nil { + return nil, errCommandRefused + } + password, err := safeio.ReadCanonicalRegular(path, maxPasswordFileBytes) + if err != nil || len(password) > passwordMaxBytes+1 { + return nil, errCommandRefused + } + password = trimOneNewline(password) + return password, validatePasswordInput(password) + } + file, ok := stdin.(*os.File) + if !ok || !term.IsTerminal(int(file.Fd())) { + return nil, errPasswordFileRequired + } + fmt.Fprint(stderr, "Password: ") + password, err := term.ReadPassword(int(file.Fd())) + fmt.Fprintln(stderr) + if err != nil { + return nil, errCommandRefused + } + fmt.Fprint(stderr, "Confirm password: ") + confirmation, err := term.ReadPassword(int(file.Fd())) + fmt.Fprintln(stderr) + if err != nil { + zero(password) + return nil, errCommandRefused + } + matched := string(password) == string(confirmation) + zero(confirmation) + if !matched { + zero(password) + return nil, errCommandRefused + } + return password, validatePasswordInput(password) +} + +func trimOneNewline(value []byte) []byte { + if bytes.HasSuffix(value, []byte("\r\n")) { + return value[:len(value)-2] + } + if bytes.HasSuffix(value, []byte("\n")) { + return value[:len(value)-1] + } + return value +} + +func validatePasswordInput(password []byte) error { + if !validPassword(password) { + return errCommandRefused + } + return nil +} + +func zero(value []byte) { + for index := range value { + value[index] = 0 + } +} + +type statusResult struct { + Mode string `json:"mode"` + PublicURL string `json:"publicUrl"` + UserCounts map[string]int `json:"userCounts"` + Revision string `json:"configRevision"` +} + +func status(directory string, args []string, stdout io.Writer) error { + jsonMode := len(args) == 1 && args[0] == "--json" + if len(args) != 0 && !jsonMode { + return errCommandRefused + } + configuration, registry, err := Load(directory) + if err != nil { + return errCommandRefused + } + counts := map[string]int{"user": 0, "admin": 0} + for _, user := range registry.Users { + for _, role := range user.Roles { + counts[string(role)]++ + } + } + revision, err := configRevision(directory, configuration.Mode) + if err != nil { + return errCommandRefused + } + result := statusResult{Mode: configuration.Mode, PublicURL: configuration.PublicURL, UserCounts: counts, Revision: revision} + if jsonMode { + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + return encoder.Encode(result) + } + _, err = fmt.Fprintf(stdout, "Authentication mode: %s\nPublic URL: %s\nUsers: user=%d admin=%d\nConfig revision: %s\n", result.Mode, result.PublicURL, counts["user"], counts["admin"], result.Revision) + return err +} + +func configRevision(directory, mode string) (string, error) { + auth, err := safeio.ReadCanonicalRegular(filepath.Join(directory, authFileName), maxYAMLBytes) + if err != nil { + return "", err + } + sum := sha256.New() + _, _ = sum.Write(auth) + if mode == "local" { + users, err := safeio.ReadCanonicalRegular(filepath.Join(directory, usersFileName), maxYAMLBytes) + if err != nil { + return "", err + } + _, _ = sum.Write(users) + } + return "sha256:" + hex.EncodeToString(sum.Sum(nil)), nil +} + +func user(directory string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if len(args) == 0 { + return errCommandRefused + } + configuration, _, err := Load(directory) + if err != nil { + return errCommandRefused + } + if configuration.Mode != "local" { + return errOIDCUsers + } + switch args[0] { + case "list": + return listUsers(directory, args[1:], stdout) + case "add": + return addUser(directory, args[1:], stdin, stderr) + case "set-password": + return setPassword(directory, args[1:], stdin, stderr) + case "enable", "disable": + return setEnabled(directory, args[0] == "enable", args[1:]) + case "grant", "revoke": + return changeRole(directory, args[0] == "grant", args[1:]) + case "logout-all": + return logoutAll(directory, args[1:]) + default: + return errCommandRefused + } +} + +func listUsers(directory string, args []string, stdout io.Writer) error { + jsonMode := len(args) == 1 && args[0] == "--json" + if len(args) != 0 && !jsonMode { + return errCommandRefused + } + _, registry, err := Load(directory) + if err != nil { + return errCommandRefused + } + if jsonMode { + return json.NewEncoder(stdout).Encode(struct { + Users []User `json:"users"` + }{Users: registry.Users}) + } + for _, item := range registry.Users { + if _, err := fmt.Fprintf(stdout, "%s\t%s\tenabled=%t\troles=%s\n", item.Username, item.DisplayName, item.Enabled, strings.Join(rolesToStrings(item.Roles), ",")); err != nil { + return err + } + } + return nil +} + +func addUser(directory string, args []string, stdin io.Reader, stderr io.Writer) error { + if len(args) == 0 { + return errCommandRefused + } + username, request, err := parseUserRolePassword(args) + if err != nil { + return errCommandRefused + } + password, err := readPassword(request.passwordFile, stdin, stderr) + if err != nil { + return err + } + defer zero(password) + hash, err := HashPassword(password, nil) + if err != nil { + return errCommandRefused + } + return MutateUsers(directory, func(registry *Registry) error { + if registry.FindByUsername(username) != nil { + return errCommandRefused + } + item, err := newUser(username, request.displayName, hash, []Role{request.role}) + if err != nil { + return errCommandRefused + } + registry.Users = append(registry.Users, item) + return nil + }) +} + +type userRequest struct { + role Role + displayName, passwordFile string +} + +func parseUserRolePassword(args []string) (string, userRequest, error) { + username := args[0] + args = args[1:] + request := userRequest{} + for len(args) > 0 { + option := args[0] + args = args[1:] + if len(args) == 0 { + return "", userRequest{}, errCommandRefused + } + value := args[0] + args = args[1:] + switch option { + case "--role": + if request.role != "" { + return "", userRequest{}, errCommandRefused + } + request.role = Role(value) + case "--display-name": + if request.displayName != "" { + return "", userRequest{}, errCommandRefused + } + request.displayName = value + case "--password-file": + if request.passwordFile != "" { + return "", userRequest{}, errCommandRefused + } + request.passwordFile = value + default: + return "", userRequest{}, errCommandRefused + } + } + if !usernamePattern.MatchString(username) || (request.role != RoleUser && request.role != RoleAdmin) { + return "", userRequest{}, errCommandRefused + } + return username, request, nil +} + +func setPassword(directory string, args []string, stdin io.Reader, stderr io.Writer) error { + if len(args) < 1 || !usernamePattern.MatchString(args[0]) { + return errCommandRefused + } + username := args[0] + passwordFile, err := parsePasswordFile(args[1:]) + if err != nil { + return errCommandRefused + } + password, err := readPassword(passwordFile, stdin, stderr) + if err != nil { + return err + } + defer zero(password) + hash, err := HashPassword(password, nil) + if err != nil { + return errCommandRefused + } + return MutateUsers(directory, func(registry *Registry) error { + item := registry.FindByUsername(username) + if item == nil { + return errCommandRefused + } + item.PasswordHash = hash + return nil + }) +} + +func parsePasswordFile(args []string) (string, error) { + if len(args) == 0 { + return "", nil + } + if len(args) != 2 || args[0] != "--password-file" || args[1] == "" { + return "", errCommandRefused + } + return args[1], nil +} + +func setEnabled(directory string, enabled bool, args []string) error { + if len(args) != 1 || !usernamePattern.MatchString(args[0]) { + return errCommandRefused + } + return MutateUsers(directory, func(registry *Registry) error { + item := registry.FindByUsername(args[0]) + if item == nil { + return errCommandRefused + } + if !enabled && item.Enabled && hasRole(item.Roles, RoleAdmin) && enabledAdminCount(*registry) == 1 { + return errLastAdministrator + } + item.Enabled = enabled + return nil + }) +} + +func changeRole(directory string, grant bool, args []string) error { + if len(args) != 3 || !usernamePattern.MatchString(args[0]) || args[1] != "--role" || (args[2] != string(RoleUser) && args[2] != string(RoleAdmin)) { + return errCommandRefused + } + role := Role(args[2]) + return MutateUsers(directory, func(registry *Registry) error { + item := registry.FindByUsername(args[0]) + if item == nil { + return errCommandRefused + } + if grant { + if hasRole(item.Roles, role) { + return errCommandRefused + } + item.Roles = append(item.Roles, role) + return nil + } + if !hasRole(item.Roles, role) || len(item.Roles) == 1 { + return errCommandRefused + } + if role == RoleAdmin && item.Enabled && enabledAdminCount(*registry) == 1 { + return errLastAdministrator + } + item.Roles = withoutRole(item.Roles, role) + return nil + }) +} + +func logoutAll(directory string, args []string) error { + if len(args) != 2 || !usernamePattern.MatchString(args[0]) || args[1] != "--yes" { + return errCommandRefused + } + return MutateUsers(directory, func(registry *Registry) error { + item := registry.FindByUsername(args[0]) + if item == nil || item.AuthRevision == ^uint64(0) { + return errCommandRefused + } + item.AuthRevision++ + return nil + }) +} + +func enabledAdminCount(registry Registry) int { + count := 0 + for _, item := range registry.Users { + if item.Enabled && hasRole(item.Roles, RoleAdmin) { + count++ + } + } + return count +} + +func withoutRole(roles []Role, unwanted Role) []Role { + result := make([]Role, 0, len(roles)-1) + for _, role := range roles { + if role != unwanted { + result = append(result, role) + } + } + return result +} + +func rolesToStrings(roles []Role) []string { + values := make([]string, len(roles)) + for index, role := range roles { + values[index] = string(role) + } + return values +} + +func validPublicURL(value string) bool { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" && parsed.User == nil && parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == "" +} + +func validOIDCURL(value string) bool { + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" +} + +func validHTTPSURL(value string) bool { + return validOIDCURL(value) && strings.TrimSuffix(value, "/") == value +} +func isHTTPS(value string) bool { + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme == "https" +} +func isLoopbackURL(value string) bool { + parsed, err := url.Parse(value) + if err != nil { + return false + } + host := parsed.Hostname() + return host == "localhost" || net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback() +} diff --git a/tools/tht/internal/authconfig/commands_test.go b/tools/tht/internal/authconfig/commands_test.go new file mode 100644 index 00000000..ccc36014 --- /dev/null +++ b/tools/tht/internal/authconfig/commands_test.go @@ -0,0 +1,187 @@ +package authconfig + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" +) + +func TestRunConfiguresLocalRegistryAndRedactsStatusJSON(t *testing.T) { + directory := newAuthDirectory(t) + passwordFile := writePasswordFile(t, "this is a local test password\n") + installation := authInstallation(directory) + + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, []string{ + "configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", + "--admin-user", "admin", "--admin-display-name", "Local administrator", "--password-file", passwordFile, + }, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("local configure = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("local configure wrote machine output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + stdout.Reset() + if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("status = %d, stderr=%q", code, stderr.String()) + } + var status struct { + Mode string `json:"mode"` + PublicURL string `json:"publicUrl"` + UserCounts map[string]int `json:"userCounts"` + Revision string `json:"configRevision"` + } + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { + t.Fatalf("status stdout is not pristine JSON: %q: %v", stdout.String(), err) + } + if status.Mode != "local" || status.PublicURL != "http://127.0.0.1:8080" || status.UserCounts["admin"] != 1 || status.Revision == "" { + t.Fatalf("status = %#v", status) + } + if strings.Contains(stdout.String()+stderr.String(), "this is a local test password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") { + t.Fatalf("status exposed password material: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestRunConfiguresOIDCFixedGroupsAndRefusesIdenticalGroups(t *testing.T) { + for name, test := range map[string]struct { + args []string + wantMode string + }{ + "configured": { + args: []string{"configure", "--mode", "oidc", "--public-url", "https://thothii.example.org", "--issuer", "https://authentik.example.org/application/o/thothii/", "--client-id", "thothii", "--authentik-base-url", "https://authentik.example.org", "--user-group", "TOT Users", "--admin-group", "TOT Admin"}, + wantMode: "oidc", + }, + "ambiguous groups": { + args: []string{"configure", "--mode", "oidc", "--public-url", "https://thothii.example.org", "--issuer", "https://authentik.example.org/application/o/thothii/", "--client-id", "thothii", "--authentik-base-url", "https://authentik.example.org", "--user-group", "same", "--admin-group", "same"}, + }, + } { + t.Run(name, func(t *testing.T) { + directory := newAuthDirectory(t) + var stdout, stderr bytes.Buffer + code := Run(context.Background(), authInstallation(directory), test.args, strings.NewReader(""), &stdout, &stderr) + if test.wantMode == "" { + if code == 0 || strings.Contains(stdout.String()+stderr.String(), "same") && strings.Contains(stdout.String()+stderr.String(), "secret") { + t.Fatalf("ambiguous groups accepted or exposed unsafe detail: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + return + } + if code != 0 { + t.Fatalf("OIDC configure = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + config, _, err := Load(directory) + if err != nil { + t.Fatal(err) + } + if config.Mode != test.wantMode || config.Authorization.GroupRoles["TOT Users"][0] != RoleUser || config.Authorization.GroupRoles["TOT Admin"][0] != RoleAdmin { + t.Fatalf("OIDC config = %#v", config) + } + }) + } +} + +func TestRunMutatesLocalUsersAndPreservesLastAdministrator(t *testing.T) { + directory := newAuthDirectory(t) + installation := authInstallation(directory) + adminPassword := writePasswordFile(t, "initial administrator password\n") + userPassword := writePasswordFile(t, "ordinary user password one\n") + if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("configure = %d", code) + } + for _, args := range [][]string{ + {"user", "add", "operator", "--role", "user", "--display-name", "Operator", "--password-file", userPassword}, + {"user", "set-password", "operator", "--password-file", adminPassword}, + {"user", "grant", "operator", "--role", "admin"}, + {"user", "revoke", "operator", "--role", "admin"}, + {"user", "disable", "operator"}, + {"user", "enable", "operator"}, + } { + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("%v = %d stdout=%q stderr=%q", args, code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String()+stderr.String(), "initial administrator password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") { + t.Fatalf("%v exposed password material", args) + } + } + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, []string{"user", "disable", "admin"}, strings.NewReader(""), &stdout, &stderr); code == 0 { + t.Fatal("last administrator disable succeeded") + } + if code := Run(context.Background(), installation, []string{"user", "revoke", "admin", "--role", "admin"}, strings.NewReader(""), &stdout, &stderr); code == 0 { + t.Fatal("last administrator revoke succeeded") + } +} + +func TestRunLogoutAllIncrementsRevisionAndUserCommandsRefuseOIDC(t *testing.T) { + directory := newAuthDirectory(t) + installation := authInstallation(directory) + passwordFile := writePasswordFile(t, "initial administrator password\n") + if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("configure = %d", code) + } + if code := Run(context.Background(), installation, []string{"user", "logout-all", "admin", "--yes"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("logout all = %d", code) + } + _, registry, err := Load(directory) + if err != nil { + t.Fatal(err) + } + if registry.Users[0].AuthRevision != 2 { + t.Fatalf("logout-all revision = %d, want 2", registry.Users[0].AuthRevision) + } + + oidcDirectory := newAuthDirectory(t) + if code := Run(context.Background(), authInstallation(oidcDirectory), []string{"configure", "--mode", "oidc", "--public-url", "https://thothii.example.org", "--issuer", "https://authentik.example.org/application/o/thothii/", "--client-id", "thothii", "--authentik-base-url", "https://authentik.example.org", "--user-group", "TOT Users", "--admin-group", "TOT Admin"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("OIDC configure = %d", code) + } + if code := Run(context.Background(), authInstallation(oidcDirectory), []string{"user", "list", "--json"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 { + t.Fatal("OIDC user command succeeded") + } +} + +func TestRunRefusesNonTTYPasswordsWithoutPasswordFile(t *testing.T) { + directory := newAuthDirectory(t) + var stdout, stderr bytes.Buffer + code := Run(context.Background(), authInstallation(directory), []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin"}, strings.NewReader("not a password source"), &stdout, &stderr) + if code == 0 || !strings.Contains(stderr.String(), "--password-file") { + t.Fatalf("non-TTY password source was accepted: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func writePasswordFile(t *testing.T, password string) string { + t.Helper() + path := filepath.Join(newAuthDirectory(t), "password") + if err := os.Mkdir(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(password), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func authInstallation(directory string) config.Installation { + installation := config.Installation{} + installation.Authentication.ConfigDirectory = directory + return installation +} + +func newAuthDirectory(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return filepath.Join(root, "auth") +} + +type ioDiscard struct{} + +func (ioDiscard) Write(contents []byte) (int, error) { return len(contents), nil } diff --git a/tools/tht/internal/authconfig/types.go b/tools/tht/internal/authconfig/types.go index 11c3ceb3..08c1f067 100644 --- a/tools/tht/internal/authconfig/types.go +++ b/tools/tht/internal/authconfig/types.go @@ -37,10 +37,10 @@ type Config struct { Mode string `yaml:"mode" json:"mode"` PublicURL string `yaml:"publicUrl" json:"publicUrl"` Session SessionConfig `yaml:"session" json:"session"` - Local LocalConfig `yaml:"local" json:"local"` - OIDC OIDCConfig `yaml:"oidc" json:"oidc"` - GroupCatalog GroupCatalogConfig `yaml:"groupCatalog" json:"groupCatalog"` - Authorization AuthorizationConfig `yaml:"authorization" json:"authorization"` + Local LocalConfig `yaml:"local,omitempty" json:"local,omitempty"` + OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` + GroupCatalog GroupCatalogConfig `yaml:"groupCatalog,omitempty" json:"groupCatalog,omitempty"` + Authorization AuthorizationConfig `yaml:"authorization,omitempty" json:"authorization,omitempty"` } type SessionConfig struct { diff --git a/tools/tht/internal/config/discovery_test.go b/tools/tht/internal/config/discovery_test.go index da4c8def..9424cc75 100644 --- a/tools/tht/internal/config/discovery_test.go +++ b/tools/tht/internal/config/discovery_test.go @@ -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) } diff --git a/tools/tht/internal/config/installation.go b/tools/tht/internal/config/installation.go index b0b29e3d..9632ba40 100644 --- a/tools/tht/internal/config/installation.go +++ b/tools/tht/internal/config/installation.go @@ -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]+$`) diff --git a/tools/tht/internal/config/installation_test.go b/tools/tht/internal/config/installation_test.go index 30019883..c6524b0f 100644 --- a/tools/tht/internal/config/installation_test.go +++ b/tools/tht/internal/config/installation_test.go @@ -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) } diff --git a/tools/tht/internal/safeio/files.go b/tools/tht/internal/safeio/files.go index cfc54b91..c41bd88a 100644 --- a/tools/tht/internal/safeio/files.go +++ b/tools/tht/internal/safeio/files.go @@ -14,6 +14,21 @@ import ( var ErrUnsafeFile = errors.New("unsafe file") +// EnsurePrivateDirectory creates only the final canonical directory with the platform's +// owner-only protection, or validates an existing directory has that protection. +func EnsurePrivateDirectory(path string) error { + if err := ValidateCanonicalPath(path); err != nil { + return err + } + if err := requireCanonicalDirectory(filepath.Dir(path)); err != nil { + return err + } + if err := createPrivateDirectory(path); err != nil && !errors.Is(err, os.ErrExist) { + return ErrUnsafeFile + } + return ValidatePrivateDirectory(path) +} + // ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened. func ValidateCanonicalPath(path string) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) { diff --git a/tools/tht/internal/safeio/private_unix.go b/tools/tht/internal/safeio/private_unix.go index 4aed559d..fdc8f1d8 100644 --- a/tools/tht/internal/safeio/private_unix.go +++ b/tools/tht/internal/safeio/private_unix.go @@ -3,10 +3,21 @@ package safeio import ( + "errors" "os" "path/filepath" ) +func createPrivateDirectory(path string) error { + if err := os.Mkdir(path, 0o700); err != nil { + if errors.Is(err, os.ErrExist) { + return os.ErrExist + } + return err + } + return ProtectPrivateDirectory(path) +} + // ProtectPrivateDirectory sets the private directory mode used for local authentication state. func ProtectPrivateDirectory(path string) error { if err := ValidateCanonicalPath(path); err != nil { diff --git a/tools/tht/internal/safeio/private_windows.go b/tools/tht/internal/safeio/private_windows.go index 69ed4e89..fb2ad335 100644 --- a/tools/tht/internal/safeio/private_windows.go +++ b/tools/tht/internal/safeio/private_windows.go @@ -3,6 +3,7 @@ package safeio import ( + "errors" "os" "path/filepath" "runtime" @@ -12,6 +13,32 @@ import ( "golang.org/x/sys/windows" ) +func createPrivateDirectory(path string) error { + parents, target, err := openCanonicalWindowsParent(path) + if err != nil { + return ErrUnsafeFile + } + defer parents.Close() + security, err := newOwnerOnlySecurityDescriptor() + if err != nil { + return ErrUnsafeFile + } + defer security.Close() + attributes := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: security.descriptor, + } + err = windows.CreateDirectory(windows.StringToUTF16Ptr(filepath.Join(parents.directory, target)), attributes) + runtime.KeepAlive(security) + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return os.ErrExist + } + if err != nil { + return ErrUnsafeFile + } + return ValidatePrivateDirectory(path) +} + // ProtectPrivateDirectory sets a protected DACL containing only the current owner. func ProtectPrivateDirectory(path string) error { parents, target, err := openCanonicalWindowsParent(path) diff --git a/tools/tht/internal/setup/files.go b/tools/tht/internal/setup/files.go index d2ff924a..26b481be 100644 --- a/tools/tht/internal/setup/files.go +++ b/tools/tht/internal/setup/files.go @@ -50,6 +50,9 @@ type generatedDescriptor struct { Branch string `yaml:"branch"` Access string `yaml:"access"` } `yaml:"workspaceRepository"` + Authentication struct { + ConfigDirectory string `yaml:"configDirectory"` + } `yaml:"authentication"` Overrides []string `yaml:"overrides"` } @@ -74,6 +77,9 @@ 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 { + return FilesResult{}, errors.New("authentication directory is unavailable or unsafe") + } result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath} descriptor, environment, err := render(root, descriptorPath, values) @@ -298,6 +304,7 @@ func installationDirectory(root, id string) (string, error) { 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") descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")} descriptorBytes, err := yaml.Marshal(descriptor) if err != nil { @@ -308,6 +315,7 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error) "THT_WORKSPACE_GIT_REMOTE=" + dotenvValue(value.workspaceRemote), "THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch), "THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID), + "THT_AUTH_CONFIG_ROOT=" + dotenvValue(descriptor.Authentication.ConfigDirectory), "THT_SECRETS_FILE=" + dotenvValue(value.secretsFile), "PI_AUTH_FILE=" + dotenvValue(value.piAuthFile), "THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4", diff --git a/tools/tht/internal/setup/files_test.go b/tools/tht/internal/setup/files_test.go index e1c9ad11..75bc3e1e 100644 --- a/tools/tht/internal/setup/files_test.go +++ b/tools/tht/internal/setup/files_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/testsupport" ) @@ -48,6 +49,13 @@ func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testi t.Errorf("generated file %s has permissions %o, want owner-only", path, info.Mode().Perm()) } } + installation, err := config.Load(result.DescriptorPath) + if err != nil { + t.Fatal(err) + } + if err := safeio.ValidatePrivateDirectory(installation.AuthenticationDirectory()); err != nil { + t.Fatalf("authentication directory is not private: %v", err) + } descriptor, err := os.ReadFile(result.DescriptorPath) if err != nil {