v3.0.0 f19f453

auth_secrets.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"golang.org/x/sys/unix"
13
14	"github.com/bnema/gordon/internal/adapters/out/secrets"
15	"github.com/bnema/gordon/internal/adapters/out/tokenstore"
16	"github.com/bnema/gordon/internal/boundaries/out"
17	"github.com/bnema/gordon/internal/domain"
18	"github.com/bnema/gordon/internal/usecase/auth"
19	"github.com/bnema/gordon/pkg/duration"
20)
21
22// createAuthService creates the authentication service and token store.
23func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
24	if !cfg.Auth.Enabled {
25		log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)")
26		return nil, nil, nil
27	}
28
29	authType, err := resolveAuthType(cfg)
30	if err != nil {
31		return nil, nil, fmt.Errorf("resolve auth type: %w", err)
32	}
33	backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
34	if err != nil {
35		return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
36	}
37	dataDir := resolveDataDir(cfg.Server.DataDir)
38
39	store, err := createTokenStore(backend, dataDir, log)
40	if err != nil {
41		return nil, nil, err
42	}
43
44	authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
45	if err != nil {
46		return nil, nil, err
47	}
48
49	authSvc := auth.NewService(authConfig, store, log)
50
51	log.Info().
52		Str("type", string(authType)).
53		Str("backend", string(backend)).
54		Msg("registry authentication enabled")
55
56	return store, authSvc, nil
57}
58
59// resolveAuthType determines the auth type from config.
60// Token-only authentication is the only supported mode.
61func resolveAuthType(cfg Config) (domain.AuthType, error) {
62	if cfg.Auth.Type != "" && cfg.Auth.Type != "token" {
63		return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type)
64	}
65	return domain.AuthTypeToken, nil
66}
67
68func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
69	switch backend {
70	case "pass":
71		return domain.SecretsBackendPass, nil
72	case "sops":
73		return domain.SecretsBackendSops, nil
74	case "unsafe":
75		return domain.SecretsBackendUnsafe, nil
76	case "":
77		return "", fmt.Errorf("auth.secrets_backend is required")
78	default:
79		return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend)
80	}
81}
82
83func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
84	// Token store is always created since tokens work in both auth modes
85	store, err := tokenstore.NewStore(backend, dataDir, log)
86	if err != nil {
87		return nil, log.WrapErr(err, "failed to create token store")
88	}
89	return store, nil
90}
91
92func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
93	authConfig := auth.Config{
94		Enabled:  cfg.Auth.Enabled,
95		AuthType: authType,
96		Username: cfg.Auth.Username,
97	}
98
99	// Token config is always required (tokens work in all auth modes)
100	secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
101	if err != nil {
102		return auth.Config{}, err
103	}
104	authConfig.TokenSecret = secret
105	authConfig.TokenExpiry = expiry
106
107	accessTokenTTL := 15 * time.Minute // default
108	if cfg.Auth.AccessTokenTTL != "" {
109		parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
110		if err != nil {
111			return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
112		}
113		if parsed <= 0 {
114			return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
115		}
116		if parsed > auth.MaxAccessTokenLifetime {
117			return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime)
118		}
119		accessTokenTTL = parsed
120	}
121	authConfig.AccessTokenTTL = accessTokenTTL
122
123	return authConfig, nil
124}
125
126func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
127	secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
128	if err != nil {
129		return nil, 0, err
130	}
131
132	expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
133	if err != nil {
134		return nil, 0, err
135	}
136
137	return secret, expiry, nil
138}
139
140// TokenSecretEnvVar is the environment variable for the JWT signing secret.
141// SECURITY: This takes priority over config file to allow secure secret injection.
142const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
143
144func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
145	// SECURITY: Priority order for token secret:
146	// 1. Environment variable (most secure - no disk exposure)
147	// 2. Secrets backend (pass/sops - encrypted)
148	// 3. Config file path (least preferred)
149
150	const minTokenSecretLength = 32
151
152	// Check environment variable first
153	if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
154		if len(envSecret) < minTokenSecretLength {
155			return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
156		}
157		log.Debug().Msg("using token secret from environment variable")
158		return []byte(envSecret), nil
159	}
160
161	// Fall back to config-specified path via secrets backend
162	if cfg.Auth.TokenSecret == "" {
163		return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
164	}
165
166	secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
167	if err != nil {
168		return nil, log.WrapErr(err, "failed to load token secret")
169	}
170
171	if len(secret) < minTokenSecretLength {
172		return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
173	}
174
175	return []byte(secret), nil
176}
177
178func parseTokenExpiry(expiry string) (time.Duration, error) {
179	if expiry == "" {
180		return 0, nil
181	}
182
183	parsed, err := duration.Parse(expiry)
184	if err != nil {
185		return 0, fmt.Errorf("invalid token_expiry: %w", err)
186	}
187
188	return parsed, nil
189}
190
191// loadSecret loads a secret from the configured backend.
192func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
193	switch backend {
194	case domain.SecretsBackendPass:
195		provider := secrets.NewPassProvider(log)
196		return provider.GetSecret(ctx, path)
197	case domain.SecretsBackendSops:
198		provider := secrets.NewSopsProvider(log)
199		return provider.GetSecret(ctx, path)
200	case domain.SecretsBackendUnsafe:
201		// For unsafe backend, path is relative to dataDir/secrets/.
202		return readUnsafeSecret(dataDir, path)
203	default:
204		return "", fmt.Errorf("unknown secrets backend: %s", backend)
205	}
206}
207
208func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
209	rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
210	if err != nil {
211		return nil, fmt.Errorf("failed to open secrets root: %w", err)
212	}
213	defer unix.Close(rootFD)
214
215	parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/")
216	dirFD := rootFD
217	var closeDirFDs []int
218	defer func() {
219		for i := len(closeDirFDs) - 1; i >= 0; i-- {
220			_ = unix.Close(closeDirFDs[i])
221		}
222	}()
223
224	for i, part := range parts {
225		if part == "" || part == "." || part == ".." {
226			return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
227		}
228		last := i == len(parts)-1
229		if last {
230			fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
231			if err != nil {
232				return nil, fmt.Errorf("failed to read secret file: %w", err)
233			}
234			defer unix.Close(fd)
235			var st unix.Stat_t
236			if err := unix.Fstat(fd, &st); err != nil {
237				return nil, fmt.Errorf("failed to stat secret file: %w", err)
238			}
239			if st.Mode&unix.S_IFMT != unix.S_IFREG {
240				return nil, fmt.Errorf("invalid secret path: secret must be a regular file")
241			}
242			data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
243			if err != nil {
244				return nil, fmt.Errorf("failed to read secret file: %w", err)
245			}
246			return data, nil
247		}
248
249		nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
250		if err != nil {
251			return nil, fmt.Errorf("failed to open secret path component: %w", err)
252		}
253		closeDirFDs = append(closeDirFDs, nextFD)
254		dirFD = nextFD
255	}
256
257	return nil, fmt.Errorf("invalid secret path: empty path")
258}
259
260type unsafeSecretProvider struct {
261	dataDir string
262}
263
264func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) }
265
266func (p unsafeSecretProvider) IsAvailable() bool { return true }
267
268func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) {
269	return readUnsafeSecret(p.dataDir, path)
270}
271
272func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider {
273	switch backend {
274	case domain.SecretsBackendPass:
275		return secrets.NewPassProvider(log)
276	case domain.SecretsBackendSops:
277		return secrets.NewSopsProvider(log)
278	case domain.SecretsBackendUnsafe:
279		return unsafeSecretProvider{dataDir: dataDir}
280	default:
281		return nil
282	}
283}
284
285func readUnsafeSecret(dataDir, secretPath string) (string, error) {
286	if filepath.IsAbs(secretPath) {
287		return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
288	}
289	cleaned := filepath.Clean(secretPath)
290	if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
291		return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets")
292	}
293
294	root := filepath.Clean(filepath.Join(dataDir, "secrets"))
295	data, err := readFileBeneath(root, cleaned)
296	if err != nil {
297		return "", err
298	}
299	return string(data), nil
300}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close