update gui
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ TAPM_GITEA_PACKAGE_WRITE_USERNAME=
|
||||
TAPM_GITEA_PACKAGE_WRITE_TOKEN=
|
||||
TAPM_ALLOWED_GITEA_USERS=taiadmin
|
||||
TAPM_COOKIE_SECRET=
|
||||
TAPM_DEFAULT_DURATION=3h
|
||||
TAPM_DEFAULT_DURATION=7d
|
||||
TAPM_DEFAULT_HOST_LIMIT=3
|
||||
TAPM_MAX_HOST_LIMIT=25
|
||||
TAPM_MAX_UPLOAD_BYTES=1073741824
|
||||
|
||||
@@ -37,6 +37,12 @@ HTTP-only Nginx test configuration. It does not invent Gitea OAuth or package
|
||||
credentials. After creating those resources in Gitea, run
|
||||
`./manage.sh configure-broker` and `./manage.sh start`.
|
||||
|
||||
New deployment codes default to seven days. While a code is active, the Codes
|
||||
page can raise or lower its host limit, add or remove packages and installer
|
||||
actions, and extend its expiration in whole days. The host limit cannot be
|
||||
reduced below the number of hosts already enrolled, and every change is
|
||||
recorded in the audit trail.
|
||||
|
||||
Once public DNS and TCP 80/443 forwarding are ready, switch the same persistent
|
||||
deployment to Let's Encrypt:
|
||||
|
||||
|
||||
@@ -124,6 +124,19 @@ launch the broker until all required credentials are present.
|
||||
OAuth token exchange and package traffic stay on the private Compose network.
|
||||
Do not publish Gitea's internal port to make broker authentication work.
|
||||
|
||||
Deployment codes default to seven days:
|
||||
|
||||
```dotenv
|
||||
TAPM_DEFAULT_DURATION=7d
|
||||
```
|
||||
|
||||
The setting accepts day values such as `7d` and legacy Go duration values such
|
||||
as `168h`. Existing deployments retain their current `.env`; change a previous
|
||||
`TAPM_DEFAULT_DURATION=3h` value to `7d` and run `./manage.sh start` to adopt
|
||||
the new default. Individual active codes can be extended from the Codes page.
|
||||
The same editor can change the maximum host count and add or remove protected
|
||||
packages and installer actions without issuing a replacement code.
|
||||
|
||||
For troubleshooting, use `./manage.sh diagnostics`. Do not share unredacted
|
||||
`docker compose config` output: Compose resolves the broker's `.env` file and
|
||||
can print cookie, OAuth, and package secrets. If configuration output is
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ umask 077
|
||||
printf 'TAPM_GITEA_PACKAGE_WRITE_TOKEN=\n'
|
||||
printf 'TAPM_ALLOWED_GITEA_USERS=taiadmin\n'
|
||||
printf 'TAPM_COOKIE_SECRET=%s\n' "$cookie_secret"
|
||||
printf 'TAPM_DEFAULT_DURATION=3h\n'
|
||||
printf 'TAPM_DEFAULT_DURATION=7d\n'
|
||||
printf 'TAPM_DEFAULT_HOST_LIMIT=3\n'
|
||||
printf 'TAPM_MAX_HOST_LIMIT=25\n'
|
||||
printf 'TAPM_MAX_UPLOAD_BYTES=1073741824\n'
|
||||
|
||||
+257
-25
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxAuthorizationDays = 90
|
||||
|
||||
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
|
||||
if hasAuditQuery(r) {
|
||||
http.Redirect(w, r, "/portal/audit?"+r.URL.RawQuery, http.StatusSeeOther)
|
||||
@@ -50,12 +53,18 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
|
||||
Actions: actions,
|
||||
NewCode: r.URL.Query().Get("code"),
|
||||
DefaultHostLimit: s.cfg.DefaultHostLimit,
|
||||
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())),
|
||||
DefaultDays: durationDays(s.cfg.DefaultDuration),
|
||||
MaxHostLimit: s.cfg.MaxHostLimit,
|
||||
Notice: r.URL.Query().Get("notice"),
|
||||
CurrentView: "codes",
|
||||
})
|
||||
}
|
||||
|
||||
func durationDays(duration time.Duration) int {
|
||||
const day = 24 * time.Hour
|
||||
return int((duration + day - 1) / day)
|
||||
}
|
||||
|
||||
func hasAuditQuery(r *http.Request) bool {
|
||||
query := r.URL.Query()
|
||||
for _, field := range []string{
|
||||
@@ -342,16 +351,19 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
|
||||
), '')
|
||||
FROM authorizations a
|
||||
WHERE a.created_at > datetime('now', '-30 days')
|
||||
OR (a.revoked_at IS NULL AND a.expires_at > CURRENT_TIMESTAMP)
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var records []authorizationRecord
|
||||
for rows.Next() {
|
||||
var record authorizationRecord
|
||||
record := authorizationRecord{
|
||||
PackageIDs: make(map[uint64]bool),
|
||||
ActionSlugs: make(map[string]bool),
|
||||
}
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.CodeHint,
|
||||
@@ -369,7 +381,76 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadAuthorizationSelections(r.Context(), records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadAuthorizationSelections(
|
||||
ctx context.Context,
|
||||
records []authorizationRecord,
|
||||
) error {
|
||||
indices := make(map[uint64]int, len(records))
|
||||
for index := range records {
|
||||
indices[records[index].ID] = index
|
||||
}
|
||||
if len(indices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
packageRows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT authorization_id, package_id FROM authorization_packages`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for packageRows.Next() {
|
||||
var authorizationID uint64
|
||||
var packageID uint64
|
||||
if err := packageRows.Scan(&authorizationID, &packageID); err != nil {
|
||||
_ = packageRows.Close()
|
||||
return err
|
||||
}
|
||||
if index, ok := indices[authorizationID]; ok {
|
||||
records[index].PackageIDs[packageID] = true
|
||||
}
|
||||
}
|
||||
if err := packageRows.Err(); err != nil {
|
||||
_ = packageRows.Close()
|
||||
return err
|
||||
}
|
||||
if err := packageRows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionRows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT authorization_id, action_slug FROM authorization_actions`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer actionRows.Close()
|
||||
for actionRows.Next() {
|
||||
var authorizationID uint64
|
||||
var actionSlug string
|
||||
if err := actionRows.Scan(&authorizationID, &actionSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
if index, ok := indices[authorizationID]; ok {
|
||||
records[index].ActionSlugs[actionSlug] = true
|
||||
}
|
||||
}
|
||||
return actionRows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -379,9 +460,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
|
||||
http.Error(w, "invalid host limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
durationHours, err := strconv.Atoi(r.FormValue("duration_hours"))
|
||||
if err != nil || durationHours < 1 || durationHours > 24 {
|
||||
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest)
|
||||
durationDays, err := strconv.Atoi(r.FormValue("duration_days"))
|
||||
if err != nil || durationDays < 1 || durationDays > maxAuthorizationDays {
|
||||
http.Error(w, "duration must be between 1 and 90 days", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
packageIDs := r.Form["package_id"]
|
||||
@@ -396,7 +477,7 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
codeHash := hashValue(code)
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour)
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(durationDays) * 24 * time.Hour)
|
||||
customerLabel := strings.TrimSpace(r.FormValue("customer_label"))
|
||||
if len(customerLabel) > 255 {
|
||||
http.Error(w, "customer label is too long", http.StatusBadRequest)
|
||||
@@ -421,14 +502,41 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
authorizationID, _ := result.LastInsertId()
|
||||
if status, err := saveAuthorizationSelections(
|
||||
r.Context(), tx, uint64(authorizationID), packageIDs, actionSlugs,
|
||||
); err != nil {
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id := uint64(authorizationID)
|
||||
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
|
||||
fmt.Sprintf("host_limit=%d duration_days=%d customer=%q", hostLimit, durationDays, customerLabel))
|
||||
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func saveAuthorizationSelections(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
authorizationID uint64,
|
||||
packageIDs []string,
|
||||
actionSlugs []string,
|
||||
) (int, error) {
|
||||
seenPackages := make(map[uint64]bool)
|
||||
for _, rawID := range packageIDs {
|
||||
packageID, err := strconv.ParseUint(rawID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid package selection", http.StatusBadRequest)
|
||||
return
|
||||
return http.StatusBadRequest, errors.New("invalid package selection")
|
||||
}
|
||||
if seenPackages[packageID] {
|
||||
continue
|
||||
}
|
||||
seenPackages[packageID] = true
|
||||
result, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
ctx,
|
||||
`INSERT INTO authorization_packages
|
||||
(authorization_id, package_id, package_slug, display_name,
|
||||
package_name, package_version, file_name, sha256)
|
||||
@@ -439,35 +547,148 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
|
||||
authorizationID, packageID,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to authorize package", http.StatusInternalServerError)
|
||||
return
|
||||
return http.StatusInternalServerError, errors.New("unable to authorize package")
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected != 1 {
|
||||
http.Error(w, "selected package is unavailable", http.StatusBadRequest)
|
||||
return
|
||||
return http.StatusBadRequest, errors.New("selected package is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
seenActions := make(map[string]bool)
|
||||
for _, actionSlug := range actionSlugs {
|
||||
actionSlug = strings.TrimSpace(actionSlug)
|
||||
if !validSlug(actionSlug) {
|
||||
http.Error(w, "invalid installer action", http.StatusBadRequest)
|
||||
return
|
||||
return http.StatusBadRequest, errors.New("invalid installer action")
|
||||
}
|
||||
if seenActions[actionSlug] {
|
||||
continue
|
||||
}
|
||||
seenActions[actionSlug] = true
|
||||
result, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
ctx,
|
||||
`INSERT INTO authorization_actions (authorization_id, action_slug)
|
||||
SELECT ?, slug FROM installer_actions
|
||||
WHERE slug = ? AND enabled = TRUE`,
|
||||
authorizationID, actionSlug,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to authorize installer action", http.StatusInternalServerError)
|
||||
return
|
||||
return http.StatusInternalServerError, errors.New("unable to authorize installer action")
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected != 1 {
|
||||
http.Error(w, "selected installer action is unavailable", http.StatusBadRequest)
|
||||
return http.StatusBadRequest, errors.New("selected installer action is unavailable")
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
tech, _ := s.currentTechnician(r)
|
||||
id, err := parseUintPath(r, "id")
|
||||
if err != nil {
|
||||
http.Error(w, "invalid authorization", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hostLimit, err := strconv.Atoi(r.FormValue("host_limit"))
|
||||
if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit {
|
||||
http.Error(w, "invalid host limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
extendDays, err := strconv.Atoi(r.FormValue("extend_days"))
|
||||
if err != nil || extendDays < 0 || extendDays > maxAuthorizationDays {
|
||||
http.Error(w, "extension must be between 0 and 90 days", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
packageIDs := r.Form["package_id"]
|
||||
actionSlugs := r.Form["action_slug"]
|
||||
if len(packageIDs) == 0 && len(actionSlugs) == 0 {
|
||||
http.Error(w, "select at least one package or installer action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var oldHostLimit int
|
||||
var hostCount int
|
||||
var expiresAt time.Time
|
||||
var revokedAt sql.NullTime
|
||||
err = tx.QueryRowContext(
|
||||
r.Context(),
|
||||
`SELECT a.host_limit,
|
||||
(SELECT COUNT(*) FROM authorization_hosts h
|
||||
WHERE h.authorization_id = a.id),
|
||||
a.expires_at, a.revoked_at
|
||||
FROM authorizations a
|
||||
WHERE a.id = ?`,
|
||||
id,
|
||||
).Scan(&oldHostLimit, &hostCount, &expiresAt, &revokedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "authorization not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if revokedAt.Valid || !time.Now().Before(expiresAt) {
|
||||
http.Error(w, "closed authorizations cannot be edited", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if hostLimit < hostCount {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("host limit cannot be lower than %d enrolled hosts", hostCount),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
newExpiresAt := expiresAt.Add(time.Duration(extendDays) * 24 * time.Hour)
|
||||
if _, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
`DELETE FROM authorization_packages WHERE authorization_id = ?`,
|
||||
id,
|
||||
); err != nil {
|
||||
http.Error(w, "unable to update authorized packages", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
`DELETE FROM authorization_actions WHERE authorization_id = ?`,
|
||||
id,
|
||||
); err != nil {
|
||||
http.Error(w, "unable to update installer actions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if status, err := saveAuthorizationSelections(
|
||||
r.Context(), tx, id, packageIDs, actionSlugs,
|
||||
); err != nil {
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
`UPDATE authorizations SET host_limit = ?, expires_at = ? WHERE id = ?`,
|
||||
hostLimit, newExpiresAt, id,
|
||||
); err != nil {
|
||||
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if extendDays > 0 {
|
||||
if _, err := tx.ExecContext(
|
||||
r.Context(),
|
||||
`UPDATE download_sessions
|
||||
SET expires_at = ?
|
||||
WHERE authorization_id = ? AND revoked_at IS NULL`,
|
||||
newExpiresAt, id,
|
||||
); err != nil {
|
||||
http.Error(w, "unable to extend download sessions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -475,10 +696,21 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
|
||||
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
id := uint64(authorizationID)
|
||||
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
|
||||
fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel))
|
||||
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
|
||||
|
||||
_ = s.audit(
|
||||
r.Context(),
|
||||
"authorization_updated",
|
||||
tech.Login,
|
||||
&id,
|
||||
"",
|
||||
"",
|
||||
s.clientIP(r),
|
||||
fmt.Sprintf(
|
||||
"host_limit=%d->%d extend_days=%d packages=%d actions=%d",
|
||||
oldHostLimit, hostLimit, extendDays, len(packageIDs), len(actionSlugs),
|
||||
),
|
||||
)
|
||||
http.Redirect(w, r, "/portal?notice=Authorization+updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func newAuthorizationTestServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
migration, err := os.ReadFile("../../migrations/001_initial.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(string(migration)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessionToken := "authorization-test-session"
|
||||
sessionHash := hashValue(sessionToken)
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO technician_sessions
|
||||
(token_hash, csrf_token, gitea_login, display_name, expires_at)
|
||||
VALUES (?, 'csrf-token', 'taiadmin', 'TAI Admin', ?)`,
|
||||
sessionHash[:], time.Now().UTC().Add(time.Hour),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Server{
|
||||
db: db,
|
||||
cfg: Config{
|
||||
MaxHostLimit: 25,
|
||||
CookieSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||||
},
|
||||
}, sessionToken
|
||||
}
|
||||
|
||||
func seedAuthorizationForUpdate(t *testing.T, server *Server) time.Time {
|
||||
t.Helper()
|
||||
for _, values := range [][]any{
|
||||
{1, "sentinelone-linux", "SentinelOne", "sentinelone-linux", "1.0", "s1.deb", strings.Repeat("a", 64), true},
|
||||
{2, "support-tool", "Support Tool", "support-tool", "2.0", "support.deb", strings.Repeat("b", 64), true},
|
||||
} {
|
||||
if _, err := server.db.Exec(
|
||||
`INSERT INTO packages
|
||||
(id, slug, display_name, package_name, package_version,
|
||||
file_name, sha256, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
values...,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
expiresAt := time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second)
|
||||
if _, err := server.db.Exec(
|
||||
`INSERT INTO authorizations
|
||||
(id, code_hash, code_hint, created_by, customer_label, host_limit, expires_at)
|
||||
VALUES (1, ?, 'ABCDE', 'taiadmin', 'Test migration', 3, ?)`,
|
||||
make([]byte, 32), expiresAt,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.db.Exec(
|
||||
`INSERT INTO authorization_packages
|
||||
(authorization_id, package_id, package_slug, display_name,
|
||||
package_name, package_version, file_name, sha256)
|
||||
SELECT 1, id, slug, display_name, package_name, package_version,
|
||||
file_name, sha256
|
||||
FROM packages WHERE id = 1`,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.db.Exec(
|
||||
`INSERT INTO authorization_hosts
|
||||
(id, authorization_id, host_fingerprint, hostname)
|
||||
VALUES
|
||||
(1, 1, ?, 'pve01'),
|
||||
(2, 1, ?, 'pve02')`,
|
||||
make([]byte, 32), bytesOf(1, 32),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.db.Exec(
|
||||
`INSERT INTO download_sessions
|
||||
(token_hash, authorization_id, authorization_host_id, expires_at)
|
||||
VALUES (?, 1, 1, ?)`,
|
||||
make([]byte, 32), expiresAt,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return expiresAt
|
||||
}
|
||||
|
||||
func authorizationUpdateRequest(
|
||||
t *testing.T,
|
||||
server *Server,
|
||||
sessionToken string,
|
||||
values url.Values,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/portal/authorizations/1/update",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
|
||||
request.SetPathValue("id", "1")
|
||||
response := httptest.NewRecorder()
|
||||
server.handleUpdateAuthorization(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) {
|
||||
server, sessionToken := newAuthorizationTestServer(t)
|
||||
oldExpiresAt := seedAuthorizationForUpdate(t, server)
|
||||
|
||||
response := authorizationUpdateRequest(t, server, sessionToken, url.Values{
|
||||
"csrf_token": {"csrf-token"},
|
||||
"host_limit": {"6"},
|
||||
"extend_days": {"7"},
|
||||
"package_id": {"2"},
|
||||
"action_slug": {"install-rmm"},
|
||||
})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
var hostLimit int
|
||||
var expiresAt time.Time
|
||||
if err := server.db.QueryRow(
|
||||
`SELECT host_limit, expires_at FROM authorizations WHERE id = 1`,
|
||||
).Scan(&hostLimit, &expiresAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hostLimit != 6 {
|
||||
t.Fatalf("host limit = %d, want 6", hostLimit)
|
||||
}
|
||||
wantExpiresAt := oldExpiresAt.Add(7 * 24 * time.Hour)
|
||||
if !expiresAt.Equal(wantExpiresAt) {
|
||||
t.Fatalf("expiration = %s, want %s", expiresAt, wantExpiresAt)
|
||||
}
|
||||
|
||||
var selectedPackage uint64
|
||||
if err := server.db.QueryRow(
|
||||
`SELECT package_id FROM authorization_packages WHERE authorization_id = 1`,
|
||||
).Scan(&selectedPackage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selectedPackage != 2 {
|
||||
t.Fatalf("selected package = %d, want 2", selectedPackage)
|
||||
}
|
||||
var selectedAction string
|
||||
if err := server.db.QueryRow(
|
||||
`SELECT action_slug FROM authorization_actions WHERE authorization_id = 1`,
|
||||
).Scan(&selectedAction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selectedAction != "install-rmm" {
|
||||
t.Fatalf("selected action = %q, want install-rmm", selectedAction)
|
||||
}
|
||||
|
||||
var sessionExpiresAt time.Time
|
||||
if err := server.db.QueryRow(
|
||||
`SELECT expires_at FROM download_sessions WHERE authorization_id = 1`,
|
||||
).Scan(&sessionExpiresAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sessionExpiresAt.Equal(wantExpiresAt) {
|
||||
t.Fatalf("session expiration = %s, want %s", sessionExpiresAt, wantExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAuthorizationRejectsLimitBelowEnrolledHosts(t *testing.T) {
|
||||
server, sessionToken := newAuthorizationTestServer(t)
|
||||
seedAuthorizationForUpdate(t, server)
|
||||
|
||||
response := authorizationUpdateRequest(t, server, sessionToken, url.Values{
|
||||
"csrf_token": {"csrf-token"},
|
||||
"host_limit": {"1"},
|
||||
"extend_days": {"0"},
|
||||
"package_id": {"1"},
|
||||
})
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func bytesOf(value byte, size int) []byte {
|
||||
result := make([]byte, size)
|
||||
for index := range result {
|
||||
result[index] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
+14
-2
@@ -76,8 +76,8 @@ func LoadConfig() (Config, error) {
|
||||
}
|
||||
cfg.CookieSecure = cfg.PublicURL.Scheme == "https"
|
||||
|
||||
cfg.DefaultDuration, err = time.ParseDuration(
|
||||
envDefault("TAPM_DEFAULT_DURATION", "3h"),
|
||||
cfg.DefaultDuration, err = parseAuthorizationDuration(
|
||||
envDefault("TAPM_DEFAULT_DURATION", "7d"),
|
||||
)
|
||||
if err != nil || cfg.DefaultDuration <= 0 {
|
||||
return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive")
|
||||
@@ -145,6 +145,18 @@ func LoadConfig() (Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseAuthorizationDuration(value string) (time.Duration, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasSuffix(value, "d") {
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(value, "d"))
|
||||
if err != nil || days < 1 {
|
||||
return 0, fmt.Errorf("invalid day duration")
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
return time.ParseDuration(value)
|
||||
}
|
||||
|
||||
func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) {
|
||||
return parseURLValue(name, os.Getenv(name), allowInsecureHTTP)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setRequiredConfigEnvironment(t *testing.T, scheme string) {
|
||||
@@ -63,3 +64,27 @@ func TestConfigAcceptsExplicitInternalGiteaURL(t *testing.T) {
|
||||
t.Fatalf("GiteaInternalURL = %q, want configured private URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAcceptsDayBasedAuthorizationDuration(t *testing.T) {
|
||||
setRequiredConfigEnvironment(t, "https")
|
||||
t.Setenv("TAPM_DEFAULT_DURATION", "7d")
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DefaultDuration != 7*24*time.Hour {
|
||||
t.Fatalf("DefaultDuration = %s, want 7 days", cfg.DefaultDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKeepsLegacyHourDurationCompatibility(t *testing.T) {
|
||||
setRequiredConfigEnvironment(t, "https")
|
||||
t.Setenv("TAPM_DEFAULT_DURATION", "3h")
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DefaultDuration != 3*time.Hour {
|
||||
t.Fatalf("DefaultDuration = %s, want 3 hours", cfg.DefaultDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ type authorizationRecord struct {
|
||||
RevokedAt sql.NullTime
|
||||
Packages string
|
||||
Actions string
|
||||
PackageIDs map[uint64]bool
|
||||
ActionSlugs map[string]bool
|
||||
}
|
||||
|
||||
type auditRecord struct {
|
||||
@@ -103,7 +105,8 @@ type pageData struct {
|
||||
FleetHosts []fleetHostRecord
|
||||
NewCode string
|
||||
DefaultHostLimit int
|
||||
DefaultDuration string
|
||||
DefaultDays int
|
||||
MaxHostLimit int
|
||||
Error string
|
||||
Notice string
|
||||
GiteaLoginURL string
|
||||
@@ -200,6 +203,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal))
|
||||
mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal))
|
||||
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
|
||||
mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization))
|
||||
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
|
||||
mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage))
|
||||
mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
|
||||
|
||||
@@ -35,7 +35,11 @@ func TestPortalTemplateExecutes(t *testing.T) {
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
Packages: "SentinelOne Linux Agent",
|
||||
Actions: "Install RMM",
|
||||
PackageIDs: map[uint64]bool{1: true},
|
||||
ActionSlugs: map[string]bool{"install-rmm": true},
|
||||
}},
|
||||
DefaultDays: 7,
|
||||
MaxHostLimit: 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -212,6 +212,12 @@ legend { margin-bottom: 8px; }
|
||||
}
|
||||
.authorization.active .status, .status.enabled { color: var(--accent-dark); border-color: rgba(105, 145, 64, .42); background: rgba(185, 217, 130, .25); }
|
||||
.code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.authorization-editor { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.authorization-editor summary { color: var(--accent-dark); cursor: pointer; font-weight: 800; }
|
||||
.authorization-editor[open] summary { margin-bottom: 18px; }
|
||||
.authorization-editor fieldset { padding: 14px; }
|
||||
.authorization-editor .button { justify-self: start; }
|
||||
.authorization-revoke { margin-top: 14px; }
|
||||
dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; }
|
||||
dl div { min-width: 0; }
|
||||
dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; }
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="policy-card">
|
||||
<span>Default policy</span>
|
||||
<strong>{{.DefaultHostLimit}} hosts</strong>
|
||||
<strong>{{.DefaultDuration}} hours</strong>
|
||||
<strong>{{.DefaultDays}} days</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -60,11 +60,11 @@
|
||||
<div class="field-row">
|
||||
<label>
|
||||
Maximum hosts
|
||||
<input name="host_limit" type="number" min="1" max="25" value="{{.DefaultHostLimit}}" required>
|
||||
<input name="host_limit" type="number" min="1" max="{{.MaxHostLimit}}" value="{{.DefaultHostLimit}}" required>
|
||||
</label>
|
||||
<label>
|
||||
Duration in hours
|
||||
<input name="duration_hours" type="number" min="1" max="24" value="{{.DefaultDuration}}" required>
|
||||
Duration in days
|
||||
<input name="duration_days" type="number" min="1" max="90" value="{{.DefaultDays}}" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
|
||||
<div class="authorization-list">
|
||||
{{range .Authorizations}}
|
||||
{{$authorization := .}}
|
||||
<article class="authorization {{if isActive .}}active{{else}}closed{{end}}">
|
||||
<div class="authorization-top">
|
||||
<div>
|
||||
@@ -132,7 +133,60 @@
|
||||
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
|
||||
</dl>
|
||||
{{if isActive .}}
|
||||
<form action="/portal/authorizations/{{.ID}}/revoke" method="post">
|
||||
<details class="authorization-editor">
|
||||
<summary>Edit access</summary>
|
||||
<form action="/portal/authorizations/{{.ID}}/update" method="post" class="stack compact">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<div class="field-row">
|
||||
<label>
|
||||
Maximum hosts
|
||||
<input name="host_limit" type="number" min="1" max="{{$.MaxHostLimit}}" value="{{.HostLimit}}" required>
|
||||
</label>
|
||||
<label>
|
||||
Extend by days
|
||||
<input name="extend_days" type="number" min="0" max="90" value="0" required>
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-help">Use 0 to keep the current expiration. Extensions are added to the existing deadline.</small>
|
||||
|
||||
<fieldset>
|
||||
<legend>Allowed packages</legend>
|
||||
{{range $.Packages}}
|
||||
{{if .Enabled}}
|
||||
<label class="package-choice">
|
||||
<input type="checkbox" name="package_id" value="{{.ID}}" {{if index $authorization.PackageIDs .ID}}checked{{end}}>
|
||||
<span>
|
||||
<strong>{{.DisplayName}}</strong>
|
||||
<small>{{.PackageVersion}}</small>
|
||||
</span>
|
||||
</label>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No packages have been enabled yet.</p>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Allowed installer actions</legend>
|
||||
{{range $.Actions}}
|
||||
{{if .Enabled}}
|
||||
<label class="package-choice">
|
||||
<input type="checkbox" name="action_slug" value="{{.Slug}}" {{if index $authorization.ActionSlugs .Slug}}checked{{end}}>
|
||||
<span>
|
||||
<strong>{{.DisplayName}}</strong>
|
||||
<small>Authorization gate</small>
|
||||
</span>
|
||||
</label>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No installer actions have been enabled.</p>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<button class="button secondary" type="submit">Save authorization</button>
|
||||
</form>
|
||||
</details>
|
||||
<form class="authorization-revoke" action="/portal/authorizations/{{.ID}}/revoke" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="text-button danger" type="submit">Revoke now</button>
|
||||
</form>
|
||||
|
||||
@@ -79,6 +79,7 @@ assert_env 'TAPM_PUBLIC_URL=http://broker.test:8780'
|
||||
assert_env 'TAPM_GITEA_URL=http://git.test:8780'
|
||||
assert_env 'TAPM_GITEA_INTERNAL_URL=http://gitea:3000'
|
||||
assert_env 'GITEA_PUBLIC_URL=http://git.test:8780/'
|
||||
assert_env 'TAPM_DEFAULT_DURATION=7d'
|
||||
grep -F -- '-f compose.yaml -f compose.http.yaml up -d gitea nginx' \
|
||||
"$MOCK_DOCKER_LOG" >/dev/null || {
|
||||
printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2
|
||||
|
||||
Reference in New Issue
Block a user