feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)

This commit is contained in:
Philipp
2026-06-12 10:45:13 +02:00
parent 137c13fa98
commit ac30a19960
33 changed files with 3625 additions and 263 deletions
+144
View File
@@ -0,0 +1,144 @@
package audit
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
type Repository interface {
ListTenantAudit(ctx context.Context, tenantID string, limit int, offset int) ([]Entry, error)
}
type SQLRepository struct {
db *sql.DB
}
func NewSQLRepository(db *sql.DB) SQLRepository {
return SQLRepository{db: db}
}
func (r SQLRepository) ListTenantAudit(ctx context.Context, tenantID string, limit int, offset int) ([]Entry, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.db.QueryContext(ctx, `
select
id::text,
tenant_id::text,
coalesce(profile_id::text, ''),
action,
target_type,
coalesce(target_id::text, ''),
metadata,
created_at
from public.audit_log
where tenant_id = $1
order by created_at desc
limit $2 offset $3
`, tenantID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []Entry
for rows.Next() {
var e Entry
var metaBytes []byte
if err := rows.Scan(&e.ID, &e.TenantID, &e.ProfileID, &e.Action, &e.TargetType, &e.TargetID, &metaBytes, &e.CreatedAt); err != nil {
return nil, err
}
if metaBytes != nil {
e.Metadata = metaBytes
} else {
e.Metadata = json.RawMessage("{}")
}
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
type Entry struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
ProfileID string `json:"profile_id"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
type Handler struct {
repository Repository
}
func NewHandler(repository Repository) Handler {
return Handler{repository: repository}
}
func (h Handler) ListTenantAudit(w http.ResponseWriter, r *http.Request) {
_, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
tenantID := r.PathValue("tenantID")
if tenantID == "" {
writeError(w, http.StatusBadRequest, "tenant_id_required")
return
}
membershipID, ok := membership.FromRequest(r)
if !ok {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if membershipID.TenantID != tenantID {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionAuditRead) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
limit := 50
offset := 0
entries, err := h.repository.ListTenantAudit(r.Context(), tenantID, limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "audit_list_failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": entries,
"limit": limit,
"offset": offset,
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
+87
View File
@@ -0,0 +1,87 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
func TestListTenantAuditReturnsEntries(t *testing.T) {
repository := &stubRepository{
entries: []Entry{{
ID: "audit-1",
TenantID: "tenant-1",
ProfileID: "profile-1",
Action: "vm.power.start",
TargetType: "vm",
TargetID: "vm-1",
Metadata: json.RawMessage(`{"cluster_id":"c-1"}`),
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
}},
}
handler := NewHandler(repository)
req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "owner")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantAudit(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var response struct {
Data []Entry `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(response.Data) != 1 {
t.Fatalf("len(data) = %d, want 1", len(response.Data))
}
if response.Data[0].ID != "audit-1" {
t.Fatalf("entry ID = %q, want audit-1", response.Data[0].ID)
}
}
func TestListTenantAuditReturnsForbiddenForViewer(t *testing.T) {
handler := NewHandler(&stubRepository{})
req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "viewer")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantAudit(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func requestWithPrincipalAndMembership(method string, target string, tenantID string, role string) *http.Request {
req := httptest.NewRequest(method, target, nil)
ctx := auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "profile-1",
Email: "user@example.test",
Role: "authenticated",
})
ctx = membership.ContextWithMembership(ctx, membership.Membership{
TenantID: tenantID,
Role: rbac.Role(role),
})
return req.WithContext(ctx)
}
type stubRepository struct {
entries []Entry
err error
}
func (s *stubRepository) ListTenantAudit(_ context.Context, _ string, _ int, _ int) ([]Entry, error) {
return s.entries, s.err
}