detached 826b93d

fix: gate and redact admin logs

commit
826b93ded41086a06283b1a95e209e985bdf8af9
author
brice · · 2026-04-25 08:55 UTC
parent
8488ad1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +202 -13
M docs/cli/auth.md +2 -1
@@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
139139
140140 Registry scopes: `push`, `pull`, `push,pull`
141141
142-Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write`
142+Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:secrets:read`, `admin:secrets:write`
143143
144144 Combine scopes with commas:
145145
@@ -280,6 +280,7 @@ Usage:
280280 | `admin:config:read` | Read-only config access |
281281 | `admin:config:write` | Config write access |
282282 | `admin:status:read` | Read-only status/health |
283+| `admin:logs:read` | Read-only logs access |
283284 | `admin:secrets:read` | List secret keys |
284285 | `admin:secrets:write` | Set/delete secrets |
285286
@@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m`
139 139
140 Registry scopes: `push`, `pull`, `push,pull`140 Registry scopes: `push`, `pull`, `push,pull`
141 141
142-Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write`142+Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:secrets:read`, `admin:secrets:write`
143 143
144 Combine scopes with commas:144 Combine scopes with commas:
145 145
@@ -280,6 +280,7 @@ Usage:
280 | `admin:config:read` | Read-only config access |280 | `admin:config:read` | Read-only config access |
281 | `admin:config:write` | Config write access |281 | `admin:config:write` | Config write access |
282 | `admin:status:read` | Read-only status/health |282 | `admin:status:read` | Read-only status/health |
283+| `admin:logs:read` | Read-only logs access |
283 | `admin:secrets:read` | List secret keys |284 | `admin:secrets:read` | List secret keys |
284 | `admin:secrets:write` | Set/delete secrets |285 | `admin:secrets:write` | Set/delete secrets |
285 286
M docs/cli/serve.md +2 -0
@@ -235,6 +235,8 @@ gordon logs [domain] [options]
235235 Remote targeting uses client config or an active remote by default.
236236 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
237237
238+Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
239+
238240 ### Examples
239241
240242 ```bash
@@ -235,6 +235,8 @@ gordon logs [domain] [options]
235 Remote targeting uses client config or an active remote by default.235 Remote targeting uses client config or an active remote by default.
236 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).236 Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
237 237
238+Remote log access requires an admin token with `admin:logs:read` (or `admin:*:*`). `admin:status:read` is not sufficient for logs.
239+
238 ### Examples240 ### Examples
239 241
240 ```bash242 ```bash
M docs/config/auth.md +1 -0
@@ -92,6 +92,7 @@ Admin scopes (for remote CLI):
9292 | `admin:config:read` | Read-only config access |
9393 | `admin:config:write` | Config write access |
9494 | `admin:status:read` | Read-only status/health |
95+| `admin:logs:read` | Read-only logs access |
9596 | `admin:secrets:read` | List secret keys |
9697 | `admin:secrets:write` | Set/delete secrets |
9798
@@ -92,6 +92,7 @@ Admin scopes (for remote CLI):
92 | `admin:config:read` | Read-only config access |92 | `admin:config:read` | Read-only config access |
93 | `admin:config:write` | Config write access |93 | `admin:config:write` | Config write access |
94 | `admin:status:read` | Read-only status/health |94 | `admin:status:read` | Read-only status/health |
95+| `admin:logs:read` | Read-only logs access |
95 | `admin:secrets:read` | List secret keys |96 | `admin:secrets:read` | List secret keys |
96 | `admin:secrets:write` | Set/delete secrets |97 | `admin:secrets:write` | Set/delete secrets |
97 98
M internal/adapters/in/cli/auth.go +2 -1
@@ -84,13 +84,14 @@ Registry scopes (for Docker push/pull):
8484 Admin scopes (for remote CLI access):
8585 Format: admin:<resource>:<actions>
8686
87- Resources: routes, secrets, config, status, * (all)
87+ Resources: routes, secrets, config, status, logs, * (all)
8888 Actions: read, write, * (all)
8989
9090 Examples:
9191 admin:*:* Full admin access (recommended for CLI)
9292 admin:routes:read Read-only routes access
9393 admin:status:read Read-only status access
94+ admin:logs:read Read-only log access
9495
9596 Repository scoping:
9697 --repo myapp Scope to specific repository
@@ -84,13 +84,14 @@ Registry scopes (for Docker push/pull):
84 Admin scopes (for remote CLI access):84 Admin scopes (for remote CLI access):
85 Format: admin:<resource>:<actions>85 Format: admin:<resource>:<actions>
86 86
87- Resources: routes, secrets, config, status, * (all)87+ Resources: routes, secrets, config, status, logs, * (all)
88 Actions: read, write, * (all)88 Actions: read, write, * (all)
89 89
90 Examples:90 Examples:
91 admin:*:* Full admin access (recommended for CLI)91 admin:*:* Full admin access (recommended for CLI)
92 admin:routes:read Read-only routes access92 admin:routes:read Read-only routes access
93 admin:status:read Read-only status access93 admin:status:read Read-only status access
94+ admin:logs:read Read-only log access
94 95
95 Repository scoping:96 Repository scoping:
96 --repo myapp Scope to specific repository97 --repo myapp Scope to specific repository
M internal/adapters/in/http/admin/handler.go +12 -7
@@ -1319,12 +1319,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
13191319 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
13201320 var deployErr *domain.DeployFailureError
13211321 if errors.As(err, &deployErr) {
1322- h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{
1322+ response := dto.DeployErrorResponse{
13231323 Error: deployErr.Error(),
13241324 Cause: deployErr.Cause,
13251325 Hint: deployErr.Hint,
1326- Logs: deployErr.Logs,
1327- })
1326+ }
1327+ if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1328+ response.Logs = domain.RedactSecretLines(deployErr.Logs)
1329+ }
1330+ h.sendJSON(w, http.StatusInternalServerError, response)
13281331 return
13291332 }
13301333 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
@@ -1455,8 +1458,8 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
14551458 log := zerowrap.FromCtx(ctx)
14561459
14571460 // Check read permission
1458- if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1459- h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1461+ if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1462+ h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read")
14601463 return
14611464 }
14621465
@@ -1522,7 +1525,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line
15221525 return
15231526 }
15241527
1525- h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})
1528+ h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)})
15261529 }
15271530
15281531 // handleContainerLogs handles container logs for a specific domain.
@@ -1546,7 +1549,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo
15461549
15471550 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
15481551 Domain: logDomain,
1549- Lines: logLines,
1552+ Lines: domain.RedactSecretLines(logLines),
15501553 })
15511554 }
15521555
@@ -1582,6 +1585,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
15821585 if !ok {
15831586 return
15841587 }
1588+ line = domain.RedactSecrets(line)
15851589 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
15861590 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
15871591 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
@@ -1628,6 +1632,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
16281632 if !ok {
16291633 return
16301634 }
1635+ line = domain.RedactSecrets(line)
16311636 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
16321637 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
16331638 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
@@ -1319,12 +1319,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1319 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")1319 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1320 var deployErr *domain.DeployFailureError1320 var deployErr *domain.DeployFailureError
1321 if errors.As(err, &deployErr) {1321 if errors.As(err, &deployErr) {
1322- h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{1322+ response := dto.DeployErrorResponse{
1323 Error: deployErr.Error(),1323 Error: deployErr.Error(),
1324 Cause: deployErr.Cause,1324 Cause: deployErr.Cause,
1325 Hint: deployErr.Hint,1325 Hint: deployErr.Hint,
1326- Logs: deployErr.Logs,1326+ }
1327- })1327+ if HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1328+ response.Logs = domain.RedactSecretLines(deployErr.Logs)
1329+ }
1330+ h.sendJSON(w, http.StatusInternalServerError, response)
1328 return1331 return
1329 }1332 }
1330 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")1333 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
@@ -1455,8 +1458,8 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
1455 log := zerowrap.FromCtx(ctx)1458 log := zerowrap.FromCtx(ctx)
1456 1459
1457 // Check read permission1460 // Check read permission
1458- if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {1461+ if !HasAccess(ctx, domain.AdminResourceLogs, domain.AdminActionRead) {
1459- h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")1462+ h.sendError(w, http.StatusForbidden, "insufficient permissions for logs:read")
1460 return1463 return
1461 }1464 }
1462 1465
@@ -1522,7 +1525,7 @@ func (h *Handler) handleProcessLogs(w http.ResponseWriter, r *http.Request, line
1522 return1525 return
1523 }1526 }
1524 1527
1525- h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: logLines})1528+ h.sendJSON(w, http.StatusOK, dto.ProcessLogsResponse{Lines: domain.RedactSecretLines(logLines)})
1526 }1529 }
1527 1530
1528 // handleContainerLogs handles container logs for a specific domain.1531 // handleContainerLogs handles container logs for a specific domain.
@@ -1546,7 +1549,7 @@ func (h *Handler) handleContainerLogs(w http.ResponseWriter, r *http.Request, lo
1546 1549
1547 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{1550 h.sendJSON(w, http.StatusOK, dto.ContainerLogsResponse{
1548 Domain: logDomain,1551 Domain: logDomain,
1549- Lines: logLines,1552+ Lines: domain.RedactSecretLines(logLines),
1550 })1553 })
1551 }1554 }
1552 1555
@@ -1582,6 +1585,7 @@ func (h *Handler) streamProcessLogs(w http.ResponseWriter, r *http.Request, line
1582 if !ok {1585 if !ok {
1583 return1586 return
1584 }1587 }
1588+ line = domain.RedactSecrets(line)
1585 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.1589 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1586 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.1590 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1587 // Per the SSE spec, multi-line data must use separate "data:" prefixes.1591 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
@@ -1628,6 +1632,7 @@ func (h *Handler) streamContainerLogs(w http.ResponseWriter, r *http.Request, lo
1628 if !ok {1632 if !ok {
1629 return1633 return
1630 }1634 }
1635+ line = domain.RedactSecrets(line)
1631 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.1636 // SECURITY: Normalize line endings and escape newlines in log lines to prevent SSE event injection.
1632 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.1637 // First normalize CRLF (\r\n) and CR (\r) to LF (\n), then escape newlines.
1633 // Per the SSE spec, multi-line data must use separate "data:" prefixes.1638 // Per the SSE spec, multi-line data must use separate "data:" prefixes.
M internal/adapters/in/http/admin/handler_test.go +73 -4
@@ -1542,6 +1542,39 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
15421542 }
15431543 }
15441544
1545+func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) {
1546+ configSvc := inmocks.NewMockConfigService(t)
1547+ containerSvc := inmocks.NewMockContainerService(t)
1548+ secretSvc := inmocks.NewMockSecretService(t)
1549+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1550+ deployErr := &domain.DeployFailureError{
1551+ Summary: "failed to deploy",
1552+ Cause: "image pull failed",
1553+ Hint: "push the image before retrying",
1554+ Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"},
1555+ }
1556+
1557+ handler := newTestHandler(t, func(d *HandlerDeps) {
1558+ d.ConfigSvc = configSvc
1559+ d.ContainerSvc = containerSvc
1560+ d.SecretSvc = secretSvc
1561+ })
1562+
1563+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1564+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1565+
1566+ req := httptest.NewRequest(http.MethodPost, "/admin/deploy/app.example.com", nil)
1567+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1568+ rec := httptest.NewRecorder()
1569+
1570+ handler.ServeHTTP(rec, req)
1571+
1572+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1573+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, rec.Body.String())
1574+ assert.NotContains(t, rec.Body.String(), "hunter2")
1575+ assert.NotContains(t, rec.Body.String(), "abc123")
1576+}
1577+
15451578 func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15461579 configSvc := inmocks.NewMockConfigService(t)
15471580 authSvc := inmocks.NewMockAuthService(t)
@@ -1553,8 +1586,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15531586 Cause: "image pull failed",
15541587 Hint: "push the image before retrying",
15551588 Logs: []string{
1556- "pulling manifest",
1557- "manifest unknown",
1589+ "pulling manifest PASSWORD=hunter2",
1590+ `{"TOKEN":"abc123"}`,
15581591 },
15591592 Err: errors.New("manifest unknown: internal registry detail"),
15601593 }
@@ -1566,7 +1599,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15661599 d.SecretSvc = secretSvc
15671600 })
15681601 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1569- handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1602+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read")))
15701603 }))
15711604 defer s.Close()
15721605
@@ -1582,8 +1615,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
15821615 assert.NoError(t, err)
15831616
15841617 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1585- assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body))
1618+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body))
15861619 assert.NotContains(t, string(body), "internal registry detail")
1620+ assert.NotContains(t, string(body), "hunter2")
1621+ assert.NotContains(t, string(body), "abc123")
15871622 }
15881623
15891624 func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
@@ -2045,6 +2080,40 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
20452080 assert.Contains(t, rec.Body.String(), "completed")
20462081 }
20472082
2083+func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
2084+ logSvc := inmocks.NewMockLogService(t)
2085+ handler := newTestHandler(t, func(d *HandlerDeps) {
2086+ d.LogSvc = logSvc
2087+ })
2088+
2089+ t.Run("status read denied", func(t *testing.T) {
2090+ req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
2091+ req = req.WithContext(ctxWithScopes("admin:status:read"))
2092+ rec := httptest.NewRecorder()
2093+
2094+ handler.ServeHTTP(rec, req)
2095+
2096+ assert.Equal(t, http.StatusForbidden, rec.Code)
2097+ assert.Contains(t, rec.Body.String(), "insufficient permissions for logs:read")
2098+ })
2099+
2100+ t.Run("logs read allowed and redacted", func(t *testing.T) {
2101+ logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
2102+
2103+ req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
2104+ req = req.WithContext(ctxWithScopes("admin:logs:read"))
2105+ rec := httptest.NewRecorder()
2106+
2107+ handler.ServeHTTP(rec, req)
2108+
2109+ assert.Equal(t, http.StatusOK, rec.Code)
2110+ assert.Contains(t, rec.Body.String(), "PASSWORD=[REDACTED]")
2111+ assert.Contains(t, rec.Body.String(), `\"API_KEY\":\"[REDACTED]\"`)
2112+ assert.NotContains(t, rec.Body.String(), "hunter2")
2113+ assert.NotContains(t, rec.Body.String(), "abc123")
2114+ })
2115+}
2116+
20482117 func TestHandler_BackupsDetectDomain(t *testing.T) {
20492118 configSvc := inmocks.NewMockConfigService(t)
20502119 authSvc := inmocks.NewMockAuthService(t)
@@ -1542,6 +1542,39 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
1542 }1542 }
1543 }1543 }
1544 1544
1545+func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) {
1546+ configSvc := inmocks.NewMockConfigService(t)
1547+ containerSvc := inmocks.NewMockContainerService(t)
1548+ secretSvc := inmocks.NewMockSecretService(t)
1549+ route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1550+ deployErr := &domain.DeployFailureError{
1551+ Summary: "failed to deploy",
1552+ Cause: "image pull failed",
1553+ Hint: "push the image before retrying",
1554+ Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"},
1555+ }
1556+
1557+ handler := newTestHandler(t, func(d *HandlerDeps) {
1558+ d.ConfigSvc = configSvc
1559+ d.ContainerSvc = containerSvc
1560+ d.SecretSvc = secretSvc
1561+ })
1562+
1563+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1564+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1565+
1566+ req := httptest.NewRequest(http.MethodPost, "/admin/deploy/app.example.com", nil)
1567+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1568+ rec := httptest.NewRecorder()
1569+
1570+ handler.ServeHTTP(rec, req)
1571+
1572+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1573+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, rec.Body.String())
1574+ assert.NotContains(t, rec.Body.String(), "hunter2")
1575+ assert.NotContains(t, rec.Body.String(), "abc123")
1576+}
1577+
1545 func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {1578 func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1546 configSvc := inmocks.NewMockConfigService(t)1579 configSvc := inmocks.NewMockConfigService(t)
1547 authSvc := inmocks.NewMockAuthService(t)1580 authSvc := inmocks.NewMockAuthService(t)
@@ -1553,8 +1586,8 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1553 Cause: "image pull failed",1586 Cause: "image pull failed",
1554 Hint: "push the image before retrying",1587 Hint: "push the image before retrying",
1555 Logs: []string{1588 Logs: []string{
1556- "pulling manifest",1589+ "pulling manifest PASSWORD=hunter2",
1557- "manifest unknown",1590+ `{"TOKEN":"abc123"}`,
1558 },1591 },
1559 Err: errors.New("manifest unknown: internal registry detail"),1592 Err: errors.New("manifest unknown: internal registry detail"),
1560 }1593 }
@@ -1566,7 +1599,7 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1566 d.SecretSvc = secretSvc1599 d.SecretSvc = secretSvc
1567 })1600 })
1568 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {1601 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1569- handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))1602+ handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read")))
1570 }))1603 }))
1571 defer s.Close()1604 defer s.Close()
1572 1605
@@ -1582,8 +1615,10 @@ func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1582 assert.NoError(t, err)1615 assert.NoError(t, err)
1583 1616
1584 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)1617 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1585- assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body))1618+ assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body))
1586 assert.NotContains(t, string(body), "internal registry detail")1619 assert.NotContains(t, string(body), "internal registry detail")
1620+ assert.NotContains(t, string(body), "hunter2")
1621+ assert.NotContains(t, string(body), "abc123")
1587 }1622 }
1588 1623
1589 func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {1624 func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
@@ -2045,6 +2080,40 @@ func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2045 assert.Contains(t, rec.Body.String(), "completed")2080 assert.Contains(t, rec.Body.String(), "completed")
2046 }2081 }
2047 2082
2083+func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
2084+ logSvc := inmocks.NewMockLogService(t)
2085+ handler := newTestHandler(t, func(d *HandlerDeps) {
2086+ d.LogSvc = logSvc
2087+ })
2088+
2089+ t.Run("status read denied", func(t *testing.T) {
2090+ req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
2091+ req = req.WithContext(ctxWithScopes("admin:status:read"))
2092+ rec := httptest.NewRecorder()
2093+
2094+ handler.ServeHTTP(rec, req)
2095+
2096+ assert.Equal(t, http.StatusForbidden, rec.Code)
2097+ assert.Contains(t, rec.Body.String(), "insufficient permissions for logs:read")
2098+ })
2099+
2100+ t.Run("logs read allowed and redacted", func(t *testing.T) {
2101+ logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
2102+
2103+ req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
2104+ req = req.WithContext(ctxWithScopes("admin:logs:read"))
2105+ rec := httptest.NewRecorder()
2106+
2107+ handler.ServeHTTP(rec, req)
2108+
2109+ assert.Equal(t, http.StatusOK, rec.Code)
2110+ assert.Contains(t, rec.Body.String(), "PASSWORD=[REDACTED]")
2111+ assert.Contains(t, rec.Body.String(), `\"API_KEY\":\"[REDACTED]\"`)
2112+ assert.NotContains(t, rec.Body.String(), "hunter2")
2113+ assert.NotContains(t, rec.Body.String(), "abc123")
2114+ })
2115+}
2116+
2048 func TestHandler_BackupsDetectDomain(t *testing.T) {2117 func TestHandler_BackupsDetectDomain(t *testing.T) {
2049 configSvc := inmocks.NewMockConfigService(t)2118 configSvc := inmocks.NewMockConfigService(t)
2050 authSvc := inmocks.NewMockAuthService(t)2119 authSvc := inmocks.NewMockAuthService(t)
M internal/domain/auth.go +6 -0
@@ -79,6 +79,7 @@ const (
7979 AdminResourceSecrets = "secrets"
8080 AdminResourceConfig = "config"
8181 AdminResourceStatus = "status"
82+ AdminResourceLogs = "logs"
8283 AdminResourceAll = "*"
8384 )
8485
@@ -302,6 +303,11 @@ func AdminScopeStatus(actions ...string) string {
302303 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
303304 }
304305
306+// AdminScopeLogs creates an admin scope for logs with the given actions.
307+func AdminScopeLogs(actions ...string) string {
308+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ","))
309+}
310+
305311 // AuthStatus represents status of an authentication session.
306312 type AuthStatus struct {
307313 Valid bool
@@ -79,6 +79,7 @@ const (
79 AdminResourceSecrets = "secrets"79 AdminResourceSecrets = "secrets"
80 AdminResourceConfig = "config"80 AdminResourceConfig = "config"
81 AdminResourceStatus = "status"81 AdminResourceStatus = "status"
82+ AdminResourceLogs = "logs"
82 AdminResourceAll = "*"83 AdminResourceAll = "*"
83 )84 )
84 85
@@ -302,6 +303,11 @@ func AdminScopeStatus(actions ...string) string {
302 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))303 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceStatus, strings.Join(actions, ","))
303 }304 }
304 305
306+// AdminScopeLogs creates an admin scope for logs with the given actions.
307+func AdminScopeLogs(actions ...string) string {
308+ return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ","))
309+}
310+
305 // AuthStatus represents status of an authentication session.311 // AuthStatus represents status of an authentication session.
306 type AuthStatus struct {312 type AuthStatus struct {
307 Valid bool313 Valid bool
M internal/domain/auth_test.go +21 -0
@@ -491,6 +491,27 @@ func TestScopesGrantAdminAccess(t *testing.T) {
491491 action: "write",
492492 want: true,
493493 },
494+ {
495+ name: "logs read grants logs resource",
496+ granted: []string{"admin:logs:read"},
497+ resource: AdminResourceLogs,
498+ action: AdminActionRead,
499+ want: true,
500+ },
501+ {
502+ name: "status read does not grant logs resource",
503+ granted: []string{"admin:status:read"},
504+ resource: AdminResourceLogs,
505+ action: AdminActionRead,
506+ want: false,
507+ },
508+ {
509+ name: "full admin wildcard grants logs resource",
510+ granted: []string{"admin:*:*"},
511+ resource: AdminResourceLogs,
512+ action: AdminActionRead,
513+ want: true,
514+ },
494515 {
495516 name: "repository scope is ignored for admin access",
496517 granted: []string{"repository:myrepo:push"},
@@ -491,6 +491,27 @@ func TestScopesGrantAdminAccess(t *testing.T) {
491 action: "write",491 action: "write",
492 want: true,492 want: true,
493 },493 },
494+ {
495+ name: "logs read grants logs resource",
496+ granted: []string{"admin:logs:read"},
497+ resource: AdminResourceLogs,
498+ action: AdminActionRead,
499+ want: true,
500+ },
501+ {
502+ name: "status read does not grant logs resource",
503+ granted: []string{"admin:status:read"},
504+ resource: AdminResourceLogs,
505+ action: AdminActionRead,
506+ want: false,
507+ },
508+ {
509+ name: "full admin wildcard grants logs resource",
510+ granted: []string{"admin:*:*"},
511+ resource: AdminResourceLogs,
512+ action: AdminActionRead,
513+ want: true,
514+ },
494 {515 {
495 name: "repository scope is ignored for admin access",516 name: "repository scope is ignored for admin access",
496 granted: []string{"repository:myrepo:push"},517 granted: []string{"repository:myrepo:push"},
A internal/domain/redact.go +46 -0
@@ -0,0 +1,46 @@
1+package domain
2+
3+import "regexp"
4+
5+type secretRedactionPattern struct {
6+ re *regexp.Regexp
7+ repl string
8+}
9+
10+const secretKeyPattern = `[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET|API[_-]?KEY|DATABASE[_-]?URL)[A-Z0-9_]*` //nolint:gosec // Regex pattern for secret-like key names, not a credential.
11+
12+var secretValuePatterns = []secretRedactionPattern{
13+ {
14+ re: regexp.MustCompile(`(?i)(\b` + secretKeyPattern + `\b\s*=\s*)([^\s]+)`),
15+ repl: `${1}[REDACTED]`,
16+ },
17+ {
18+ re: regexp.MustCompile(`(?i)("` + secretKeyPattern + `"\s*:\s*")([^"]*)(")`),
19+ repl: `${1}[REDACTED]${3}`,
20+ },
21+ {
22+ re: regexp.MustCompile(`(?i)(\bAuthorization\s*:\s*Bearer\s+)([^\s]+)`),
23+ repl: `${1}[REDACTED]`,
24+ },
25+}
26+
27+// RedactSecrets redacts common secret values from a single log or diagnostic line.
28+func RedactSecrets(line string) string {
29+ redacted := line
30+ for _, pattern := range secretValuePatterns {
31+ redacted = pattern.re.ReplaceAllString(redacted, pattern.repl)
32+ }
33+ return redacted
34+}
35+
36+// RedactSecretLines redacts common secret values from log or diagnostic lines.
37+func RedactSecretLines(lines []string) []string {
38+ if len(lines) == 0 {
39+ return nil
40+ }
41+ redacted := make([]string, len(lines))
42+ for i, line := range lines {
43+ redacted[i] = RedactSecrets(line)
44+ }
45+ return redacted
46+}
@@ -0,0 +1,46 @@
1+package domain
2+
3+import "regexp"
4+
5+type secretRedactionPattern struct {
6+ re *regexp.Regexp
7+ repl string
8+}
9+
10+const secretKeyPattern = `[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET|API[_-]?KEY|DATABASE[_-]?URL)[A-Z0-9_]*` //nolint:gosec // Regex pattern for secret-like key names, not a credential.
11+
12+var secretValuePatterns = []secretRedactionPattern{
13+ {
14+ re: regexp.MustCompile(`(?i)(\b` + secretKeyPattern + `\b\s*=\s*)([^\s]+)`),
15+ repl: `${1}[REDACTED]`,
16+ },
17+ {
18+ re: regexp.MustCompile(`(?i)("` + secretKeyPattern + `"\s*:\s*")([^"]*)(")`),
19+ repl: `${1}[REDACTED]${3}`,
20+ },
21+ {
22+ re: regexp.MustCompile(`(?i)(\bAuthorization\s*:\s*Bearer\s+)([^\s]+)`),
23+ repl: `${1}[REDACTED]`,
24+ },
25+}
26+
27+// RedactSecrets redacts common secret values from a single log or diagnostic line.
28+func RedactSecrets(line string) string {
29+ redacted := line
30+ for _, pattern := range secretValuePatterns {
31+ redacted = pattern.re.ReplaceAllString(redacted, pattern.repl)
32+ }
33+ return redacted
34+}
35+
36+// RedactSecretLines redacts common secret values from log or diagnostic lines.
37+func RedactSecretLines(lines []string) []string {
38+ if len(lines) == 0 {
39+ return nil
40+ }
41+ redacted := make([]string, len(lines))
42+ for i, line := range lines {
43+ redacted[i] = RedactSecrets(line)
44+ }
45+ return redacted
46+}
A internal/domain/redact_test.go +37 -0
@@ -0,0 +1,37 @@
1+package domain
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRedactSecrets(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ want string
14+ }{
15+ {"password env", "PASSWORD=hunter2", "PASSWORD=[REDACTED]"},
16+ {"token env", "TOKEN=abc123", "TOKEN=[REDACTED]"},
17+ {"secret env", "SECRET=shh", "SECRET=[REDACTED]"},
18+ {"api key env", "API_KEY=key-value", "API_KEY=[REDACTED]"},
19+ {"database url env", "DATABASE_URL=postgres://user:pass@db/app", "DATABASE_URL=[REDACTED]"},
20+ {"json password field", `{"PASSWORD":"hunter2","ok":"value"}`, `{"PASSWORD":"[REDACTED]","ok":"value"}`},
21+ {"json token field with spaces", `{"TOKEN": "abc123"}`, `{"TOKEN": "[REDACTED]"}`},
22+ {"case insensitive", "database_url=postgres://user:pass@db/app", "database_url=[REDACTED]"},
23+ {"prefixed password env", "POSTGRES_PASSWORD=hunter2", "POSTGRES_PASSWORD=[REDACTED]"},
24+ {"prefixed token env", "GORDON_TOKEN=abc123", "GORDON_TOKEN=[REDACTED]"},
25+ {"secret key env", "SECRET_KEY=shh", "SECRET_KEY=[REDACTED]"},
26+ {"api token env", "API_TOKEN=abc123", "API_TOKEN=[REDACTED]"},
27+ {"authorization bearer", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret", "Authorization: Bearer [REDACTED]"},
28+ {"json prefixed password field", `{"POSTGRES_PASSWORD":"hunter2"}`, `{"POSTGRES_PASSWORD":"[REDACTED]"}`},
29+ {"json secret key field", `{"secret_key": "shh"}`, `{"secret_key": "[REDACTED]"}`},
30+ }
31+
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ assert.Equal(t, tt.want, RedactSecrets(tt.in))
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package domain
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRedactSecrets(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in string
13+ want string
14+ }{
15+ {"password env", "PASSWORD=hunter2", "PASSWORD=[REDACTED]"},
16+ {"token env", "TOKEN=abc123", "TOKEN=[REDACTED]"},
17+ {"secret env", "SECRET=shh", "SECRET=[REDACTED]"},
18+ {"api key env", "API_KEY=key-value", "API_KEY=[REDACTED]"},
19+ {"database url env", "DATABASE_URL=postgres://user:pass@db/app", "DATABASE_URL=[REDACTED]"},
20+ {"json password field", `{"PASSWORD":"hunter2","ok":"value"}`, `{"PASSWORD":"[REDACTED]","ok":"value"}`},
21+ {"json token field with spaces", `{"TOKEN": "abc123"}`, `{"TOKEN": "[REDACTED]"}`},
22+ {"case insensitive", "database_url=postgres://user:pass@db/app", "database_url=[REDACTED]"},
23+ {"prefixed password env", "POSTGRES_PASSWORD=hunter2", "POSTGRES_PASSWORD=[REDACTED]"},
24+ {"prefixed token env", "GORDON_TOKEN=abc123", "GORDON_TOKEN=[REDACTED]"},
25+ {"secret key env", "SECRET_KEY=shh", "SECRET_KEY=[REDACTED]"},
26+ {"api token env", "API_TOKEN=abc123", "API_TOKEN=[REDACTED]"},
27+ {"authorization bearer", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret", "Authorization: Bearer [REDACTED]"},
28+ {"json prefixed password field", `{"POSTGRES_PASSWORD":"hunter2"}`, `{"POSTGRES_PASSWORD":"[REDACTED]"}`},
29+ {"json secret key field", `{"secret_key": "shh"}`, `{"secret_key": "[REDACTED]"}`},
30+ }
31+
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ assert.Equal(t, tt.want, RedactSecrets(tt.in))
35+ })
36+ }
37+}

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