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

This commit is contained in:
2026-08-16 19:23:30 +02:00
parent d17ad0e95b
commit 9646ae09a0
15 changed files with 992 additions and 13 deletions
+14
View File
@@ -14,6 +14,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/backup" "github.com/aritmolab/thothii/tools/tht/internal/backup"
"github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config" "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/output"
"github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/project" "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/serverops"
"github.com/aritmolab/thothii/tools/tht/internal/service" "github.com/aritmolab/thothii/tools/tht/internal/service"
"github.com/aritmolab/thothii/tools/tht/internal/setup" "github.com/aritmolab/thothii/tools/tht/internal/setup"
@@ -38,6 +40,10 @@ Commands:
setup [--configure-only] [--installation-id ID] [--profile local|server] setup [--configure-only] [--installation-id ID] [--profile local|server]
Create or validate the local non-secret installation configuration. Create or validate the local non-secret installation configuration.
version [--json] Show the host CLI build identity. 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. status Show the Compose service state.
doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics. doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics.
logs Show the latest 200 sanitized service log lines (bounded; no follow mode). 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)) fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
return 2 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() secretFiles, err := installation.SecretFiles()
if err != nil { if err != nil {
fmt.Fprintln(stderr, "tht: installation secret declarations could not be read") 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("") runner := compose.NewRunner("")
var result compose.Result var result compose.Result
switch command { switch command {
case "auth":
return authconfig.Run(ctx, installation, commandArgs, os.Stdin, stdout, stderr)
case "status": case "status":
if len(commandArgs) != 0 { if len(commandArgs) != 0 {
return commandUsageError(stderr, "status does not accept arguments") return commandUsageError(stderr, "status does not accept arguments")
+8 -3
View File
@@ -201,7 +201,7 @@ func TestRootCommandIdentity(t *testing.T) {
name: "help banner", name: "help banner",
args: []string{"--help"}, args: []string{"--help"},
wantCode: 0, wantCode: 0,
wantText: "Usage: tht ", wantText: " auth configure",
}, },
{ {
name: "version path", name: "version path",
@@ -1446,8 +1446,12 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
} }
} }
envFile := filepath.Join(root, "installation.env") 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") 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 { if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err) 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) { func (f cliFixture) setEnvContents(t *testing.T, env string) {
t.Helper() 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 { if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil {
t.Fatal(err) 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 { if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err) 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 { if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1
View File
@@ -13,6 +13,7 @@ require (
github.com/sirupsen/logrus v1.9.1 github.com/sirupsen/logrus v1.9.1
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
) )
require ( require (
+2
View File
@@ -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.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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+628
View File
@@ -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()
}
@@ -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 }
+4 -4
View File
@@ -37,10 +37,10 @@ type Config struct {
Mode string `yaml:"mode" json:"mode"` Mode string `yaml:"mode" json:"mode"`
PublicURL string `yaml:"publicUrl" json:"publicUrl"` PublicURL string `yaml:"publicUrl" json:"publicUrl"`
Session SessionConfig `yaml:"session" json:"session"` Session SessionConfig `yaml:"session" json:"session"`
Local LocalConfig `yaml:"local" json:"local"` Local LocalConfig `yaml:"local,omitempty" json:"local,omitempty"`
OIDC OIDCConfig `yaml:"oidc" json:"oidc"` OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"`
GroupCatalog GroupCatalogConfig `yaml:"groupCatalog" json:"groupCatalog"` GroupCatalog GroupCatalogConfig `yaml:"groupCatalog,omitempty" json:"groupCatalog,omitempty"`
Authorization AuthorizationConfig `yaml:"authorization" json:"authorization"` Authorization AuthorizationConfig `yaml:"authorization,omitempty" json:"authorization,omitempty"`
} }
type SessionConfig struct { type SessionConfig struct {
+9 -3
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -188,12 +189,17 @@ func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string)
if err := os.MkdirAll(directory, 0o755); err != nil { if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
envFile := filepath.Join(directory, "operator.env") physicalDirectory, err := filepath.EvalSymlinks(directory)
if err := os.WriteFile(envFile, nil, 0o600); err != nil { 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) t.Fatal(err)
} }
installationPath := filepath.Join(directory, "thothii-installation.yaml") 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 { if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+26 -1
View File
@@ -34,9 +34,14 @@ type descriptor struct {
ProjectDirectory string `yaml:"projectDirectory"` ProjectDirectory string `yaml:"projectDirectory"`
EnvFile string `yaml:"envFile"` EnvFile string `yaml:"envFile"`
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"` WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
Authentication authenticationDescriptor `yaml:"authentication"`
Overrides []string `yaml:"overrides"` Overrides []string `yaml:"overrides"`
} }
type authenticationDescriptor struct {
ConfigDirectory string `yaml:"configDirectory"`
}
type workspaceRepositoryDescriptor struct { type workspaceRepositoryDescriptor struct {
Remote string `yaml:"remote"` Remote string `yaml:"remote"`
Branch string `yaml:"branch"` Branch string `yaml:"branch"`
@@ -50,6 +55,11 @@ type WorkspaceRepository struct {
Access string 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 // Installation is a validated local Compose installation. It intentionally contains paths, not
// environment values or secret content. // environment values or secret content.
type Installation struct { type Installation struct {
@@ -58,6 +68,7 @@ type Installation struct {
ProjectDirectory string ProjectDirectory string
EnvFile string EnvFile string
WorkspaceRepository WorkspaceRepository WorkspaceRepository WorkspaceRepository
Authentication Authentication
Overrides []string Overrides []string
} }
@@ -99,6 +110,9 @@ func Load(path string) (Installation, error) {
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil { if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
return Installation{}, err 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{ installation := Installation{
Path: path, Path: path,
@@ -110,7 +124,15 @@ func Load(path string) (Installation, error) {
Branch: raw.WorkspaceRepository.Branch, Branch: raw.WorkspaceRepository.Branch,
Access: raw.WorkspaceRepository.Access, 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 { for _, override := range raw.Overrides {
if err := requireRegularFile(override, "override"); err != nil { if err := requireRegularFile(override, "override"); err != nil {
@@ -136,6 +158,9 @@ func Load(path string) (Installation, error) {
return installation, nil 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 safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`) var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`)
+44 -2
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -67,6 +68,7 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
} }
remote := "git@gitea.example.org:clinical/workspaces.git" remote := "git@gitea.example.org:clinical/workspaces.git"
environment := strings.Join([]string{ environment := strings.Join([]string{
"THT_AUTH_CONFIG_ROOT=" + strconv.Quote(filepath.Join(filepath.Dir(envFile), "auth")),
"THT_WORKSPACE_GIT_REMOTE=" + remote, "THT_WORKSPACE_GIT_REMOTE=" + remote,
"THT_WORKSPACE_GIT_BRANCH=main", "THT_WORKSPACE_GIT_BRANCH=main",
"THT_WORKSPACE_GIT_SSH_KEY_FILE=" + privateKey, "THT_WORKSPACE_GIT_SSH_KEY_FILE=" + privateKey,
@@ -77,6 +79,7 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
} }
contents := "profile: local\nprojectDirectory: " + projectDirectory + contents := "profile: local\nprojectDirectory: " + projectDirectory +
"\nenvFile: " + envFile + "\nenvFile: " + envFile +
"\nauthentication:\n configDirectory: " + filepath.Join(filepath.Dir(envFile), "auth") +
"\nworkspaceRepository:\n remote: " + remote + "\nworkspaceRepository:\n remote: " + remote +
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n" "\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
@@ -102,6 +105,7 @@ func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) {
} }
contents := "profile: local\nprojectDirectory: " + projectDirectory + contents := "profile: local\nprojectDirectory: " + projectDirectory +
"\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n" "\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 { if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -194,6 +198,7 @@ func TestPreservationPathsReturnsCanonicalBindRootsBackupsAndSecretFiles(t *test
} }
wanted = append(wanted, secret) wanted = append(wanted, secret)
lines = append(lines, "APP_TOKEN_FILE="+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 { if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err) 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) { func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
t.Helper() t.Helper()
@@ -262,7 +303,8 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
} }
} }
envFile := filepath.Join(root, "environment file.env") 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) t.Fatal(err)
} }
override := filepath.Join(root, "extra override.yaml") 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) t.Fatal(err)
} }
installationPath := filepath.Join(root, "thothii-installation.yaml") 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 { if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+15
View File
@@ -14,6 +14,21 @@ import (
var ErrUnsafeFile = errors.New("unsafe file") 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. // ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
func ValidateCanonicalPath(path string) error { func ValidateCanonicalPath(path string) error {
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) { if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
+11
View File
@@ -3,10 +3,21 @@
package safeio package safeio
import ( import (
"errors"
"os" "os"
"path/filepath" "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. // ProtectPrivateDirectory sets the private directory mode used for local authentication state.
func ProtectPrivateDirectory(path string) error { func ProtectPrivateDirectory(path string) error {
if err := ValidateCanonicalPath(path); err != nil { if err := ValidateCanonicalPath(path); err != nil {
@@ -3,6 +3,7 @@
package safeio package safeio
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -12,6 +13,32 @@ import (
"golang.org/x/sys/windows" "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. // ProtectPrivateDirectory sets a protected DACL containing only the current owner.
func ProtectPrivateDirectory(path string) error { func ProtectPrivateDirectory(path string) error {
parents, target, err := openCanonicalWindowsParent(path) parents, target, err := openCanonicalWindowsParent(path)
+8
View File
@@ -50,6 +50,9 @@ type generatedDescriptor struct {
Branch string `yaml:"branch"` Branch string `yaml:"branch"`
Access string `yaml:"access"` Access string `yaml:"access"`
} `yaml:"workspaceRepository"` } `yaml:"workspaceRepository"`
Authentication struct {
ConfigDirectory string `yaml:"configDirectory"`
} `yaml:"authentication"`
Overrides []string `yaml:"overrides"` Overrides []string `yaml:"overrides"`
} }
@@ -74,6 +77,9 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
} }
descriptorPath := filepath.Join(directory, descriptorName) descriptorPath := filepath.Join(directory, descriptorName)
environmentPath := filepath.Join(directory, environmentName) 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} result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath}
descriptor, environment, err := render(root, descriptorPath, values) 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) { 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 := 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.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")} descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")}
descriptorBytes, err := yaml.Marshal(descriptor) descriptorBytes, err := yaml.Marshal(descriptor)
if err != nil { 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_REMOTE=" + dotenvValue(value.workspaceRemote),
"THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch), "THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch),
"THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID), "THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID),
"THT_AUTH_CONFIG_ROOT=" + dotenvValue(descriptor.Authentication.ConfigDirectory),
"THT_SECRETS_FILE=" + dotenvValue(value.secretsFile), "THT_SECRETS_FILE=" + dotenvValue(value.secretsFile),
"PI_AUTH_FILE=" + dotenvValue(value.piAuthFile), "PI_AUTH_FILE=" + dotenvValue(value.piAuthFile),
"THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4", "THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4",
+8
View File
@@ -11,6 +11,7 @@ import (
"testing" "testing"
"github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport" "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()) 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) descriptor, err := os.ReadFile(result.DescriptorPath)
if err != nil { if err != nil {