Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
@@ -64,9 +64,14 @@ func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil),
|
||||
httptest.NewRequest(
|
||||
http.MethodDelete,
|
||||
"/api/v1/users?id=test-user-id",
|
||||
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -126,11 +131,13 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
reauthBody := `{"current_password":"correct horse battery staple extra"}`
|
||||
|
||||
deleteOperator := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)),
|
||||
cookie,
|
||||
)
|
||||
deleteOperator.Header.Set("Content-Type", "application/json")
|
||||
operatorRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(operatorRec, deleteOperator)
|
||||
if operatorRec.Code != http.StatusOK {
|
||||
@@ -138,9 +145,10 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
deleteSecondAdmin := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil),
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)),
|
||||
cookie,
|
||||
)
|
||||
deleteSecondAdmin.Header.Set("Content-Type", "application/json")
|
||||
adminRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(adminRec, deleteSecondAdmin)
|
||||
if adminRec.Code != http.StatusOK {
|
||||
@@ -182,13 +190,275 @@ func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil),
|
||||
httptest.NewRequest(
|
||||
http.MethodDelete,
|
||||
"/api/v1/users?id=missing",
|
||||
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersCreateAndPatch(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Groups = append(payload.Groups, settings.Group{
|
||||
Name: "Operators",
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Ops",
|
||||
Permissions: []string{"nodes.read"},
|
||||
})
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/users",
|
||||
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
var created usersResponseTest
|
||||
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
var aliceID string
|
||||
for _, user := range created.Users {
|
||||
if user.Username == "alice" {
|
||||
aliceID = user.ID
|
||||
if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" {
|
||||
t.Fatalf("unexpected groups: %#v", user.GroupNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
if aliceID == "" {
|
||||
t.Fatal("alice not found after create")
|
||||
}
|
||||
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/users?id="+aliceID,
|
||||
strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchReq.Header.Set("Content-Type", "application/json")
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String())
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPasswords: %v", err)
|
||||
}
|
||||
for _, user := range store.Users {
|
||||
if user.ID != aliceID {
|
||||
continue
|
||||
}
|
||||
if user.TOTPConfirmed || user.TOTPSecret != "" {
|
||||
t.Fatal("expected TOTP cleared")
|
||||
}
|
||||
ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected password updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersWriteRequiresPermission(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
// Strip users.manage from Administrators
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
filtered := make([]string, 0)
|
||||
for _, permission := range payload.Groups[index].Permissions {
|
||||
if permission == "users.manage" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
payload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
_ = key
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/users",
|
||||
strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPatchRequiresGraceReauth(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Security.ReauthSensitiveActions = true
|
||||
payload.Security.ReauthGraceMinutes = 0
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
// Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran.
|
||||
store, err := settings.LoadSessionsOrEmpty(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
for index := range store.Sessions {
|
||||
store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute)
|
||||
}
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/users?id=test-user-id",
|
||||
strings.NewReader(`{"password":"another strong passphrase here"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchReq.Header.Set("Content-Type", "application/json")
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String())
|
||||
}
|
||||
var errPayload apiErrorResponseTest
|
||||
if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if errPayload.Error != "reauth_required" {
|
||||
t.Fatalf("expected reauth_required, got %q", errPayload.Error)
|
||||
}
|
||||
|
||||
reauthReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/reauth",
|
||||
strings.NewReader(`{"password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
reauthReq.Header.Set("Content-Type", "application/json")
|
||||
reauthRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(reauthRec, reauthReq)
|
||||
if reauthRec.Code != http.StatusOK {
|
||||
t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String())
|
||||
}
|
||||
|
||||
// Refresh cookie if rotated
|
||||
for _, setCookie := range reauthRec.Result().Cookies() {
|
||||
if setCookie.Name == auth.SessionCookieName {
|
||||
cookie = setCookie
|
||||
}
|
||||
}
|
||||
|
||||
retryReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/users?id=test-user-id",
|
||||
strings.NewReader(`{"password":"another strong passphrase here"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
retryRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(retryRec, retryReq)
|
||||
if retryRec.Code != http.StatusOK {
|
||||
t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMePasswordChange(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/me/password",
|
||||
strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
// Session should be invalidated
|
||||
meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie)
|
||||
meRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(meRec, meReq)
|
||||
if meRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected session invalidated, got %d", meRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user