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
+205
View File
@@ -0,0 +1,205 @@
package sshkey
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
type Repository interface {
List(ctx context.Context, profileID string, tenantID string) ([]SSHKey, bool, error)
Get(ctx context.Context, profileID string, keyID string) (SSHKey, bool, error)
Create(ctx context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error)
Delete(ctx context.Context, profileID string, keyID string) (bool, bool, error)
}
type Handler struct {
repository Repository
}
func NewHandler(repository Repository) Handler {
return Handler{repository: repository}
}
func (h Handler) ListTenantKeys(w http.ResponseWriter, r *http.Request) {
principal, 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
}
keys, found, err := h.repository.List(r.Context(), principal.Subject, tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_keys_list_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "tenant_not_found")
return
}
writeJSON(w, http.StatusOK, map[string][]SSHKey{"data": keys})
}
func (h Handler) GetKey(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
keyID := r.PathValue("keyID")
if keyID == "" {
writeError(w, http.StatusBadRequest, "key_id_required")
return
}
key, found, err := h.repository.Get(r.Context(), principal.Subject, keyID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_get_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "ssh_key_not_found")
return
}
membershipID, _ := membership.FromRequest(r)
if membershipID.TenantID != key.TenantID {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyRead) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
writeJSON(w, http.StatusOK, key)
}
func (h Handler) CreateKey(w http.ResponseWriter, r *http.Request) {
principal, 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, _ := membership.FromRequest(r)
if membershipID.TenantID != tenantID {
writeError(w, http.StatusForbidden, "forbidden")
return
}
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyManage) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req createRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name_required")
return
}
req.PublicKey = strings.TrimSpace(req.PublicKey)
if req.PublicKey == "" {
writeError(w, http.StatusBadRequest, "public_key_required")
return
}
key, found, err := h.repository.Create(r.Context(), principal.Subject, tenantID, req.Name, req.PublicKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_create_failed")
return
}
if !found {
writeError(w, http.StatusNotFound, "tenant_not_found")
return
}
writeJSON(w, http.StatusCreated, key)
}
func (h Handler) DeleteKey(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
keyID := r.PathValue("keyID")
if keyID == "" {
writeError(w, http.StatusBadRequest, "key_id_required")
return
}
deleted, member, err := h.repository.Delete(r.Context(), principal.Subject, keyID)
if err != nil {
writeError(w, http.StatusInternalServerError, "ssh_key_delete_failed")
return
}
if !member {
writeError(w, http.StatusNotFound, "ssh_key_not_found")
return
}
if !deleted {
writeError(w, http.StatusNotFound, "ssh_key_not_found")
return
}
membershipID, _ := membership.FromRequest(r)
if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyManage) {
writeError(w, http.StatusForbidden, "forbidden")
return
}
w.WriteHeader(http.StatusNoContent)
}
type SSHKey struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
CreatedAt time.Time `json:"created_at"`
}
type createRequest struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
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})
}
+310
View File
@@ -0,0 +1,310 @@
package sshkey
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"proxui/backend/internal/auth"
"proxui/backend/internal/membership"
"proxui/backend/internal/rbac"
)
func TestListTenantKeysReturnsKeys(t *testing.T) {
repository := &stubRepository{
listFound: true,
keys: []SSHKey{{
ID: "key-1",
TenantID: "tenant-1",
Name: "laptop",
PublicKey: "ssh-ed25519 AAAAC3...",
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
}},
}
handler := NewHandler(repository)
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if repository.profileID != "profile-1" {
t.Fatalf("profileID = %q, want profile-1", repository.profileID)
}
if repository.tenantID != "tenant-1" {
t.Fatalf("tenantID = %q, want tenant-1", repository.tenantID)
}
var response struct {
Data []SSHKey `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 != "key-1" {
t.Fatalf("key ID = %q, want key-1", response.Data[0].ID)
}
}
func TestListTenantKeysReturnsEmptyListForTenantWithNoKeys(t *testing.T) {
handler := NewHandler(&stubRepository{listFound: true, keys: []SSHKey{}})
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var response struct {
Data []SSHKey `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.Data == nil {
t.Fatal("data should be an empty slice, not nil")
}
}
func TestListTenantKeysReturnsNotFound(t *testing.T) {
handler := NewHandler(&stubRepository{listFound: false})
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestGetKeyReturnsKey(t *testing.T) {
repository := &stubRepository{
getFound: true,
key: SSHKey{
ID: "key-1",
TenantID: "tenant-1",
Name: "laptop",
PublicKey: "ssh-ed25519 AAAAC3...",
},
}
handler := NewHandler(repository)
req := requestWithPrincipalAndMembership(http.MethodGet, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.GetKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if repository.keyID != "key-1" {
t.Fatalf("keyID = %q, want key-1", repository.keyID)
}
var response SSHKey
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.ID != "key-1" {
t.Fatalf("key ID = %q, want key-1", response.ID)
}
}
func TestGetKeyReturnsNotFound(t *testing.T) {
handler := NewHandler(&stubRepository{getFound: false})
req := requestWithPrincipalAndMembership(http.MethodGet, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.GetKey(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCreateKeyReturnsCreated(t *testing.T) {
repository := &stubRepository{
createFound: true,
createResult: SSHKey{
ID: "key-1",
TenantID: "tenant-1",
Name: "laptop",
PublicKey: "ssh-ed25519 AAAAC3...",
},
}
handler := NewHandler(repository)
body := `{"name":"laptop","public_key":"ssh-ed25519 AAAAC3..."}`
req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "owner")
req.SetPathValue("tenantID", "tenant-1")
req.Body = io.NopCloser(strings.NewReader(body))
rec := httptest.NewRecorder()
handler.CreateKey(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
}
var response SSHKey
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response.ID != "key-1" {
t.Fatalf("key ID = %q, want key-1", response.ID)
}
}
func TestCreateKeyReturnsForbiddenForViewer(t *testing.T) {
handler := NewHandler(&stubRepository{})
body := `{"name":"laptop","public_key":"ssh-ed25519 AAAAC3..."}`
req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "viewer")
req.SetPathValue("tenantID", "tenant-1")
req.Body = io.NopCloser(strings.NewReader(body))
rec := httptest.NewRecorder()
handler.CreateKey(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestDeleteKeyReturnsNoContent(t *testing.T) {
repository := &stubRepository{
deleteResult: true,
deleteMember: true,
}
handler := NewHandler(repository)
req := requestWithPrincipalAndMembership(http.MethodDelete, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.DeleteKey(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if repository.deleteKeyID != "key-1" {
t.Fatalf("keyID = %q, want key-1", repository.deleteKeyID)
}
}
func TestDeleteKeyReturnsNotFound(t *testing.T) {
repo := &stubRepository{deleteResult: false, deleteMember: true}
handler := NewHandler(repo)
req := requestWithPrincipalAndMembership(http.MethodDelete, "/ssh-keys/key-1", "tenant-1", "owner")
req.SetPathValue("keyID", "key-1")
rec := httptest.NewRecorder()
handler.DeleteKey(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestCreateKeyRejectsEmptyName(t *testing.T) {
handler := NewHandler(&stubRepository{})
body := `{"name":" ","public_key":"ssh-ed25519 AAAAC3..."}`
req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "owner")
req.SetPathValue("tenantID", "tenant-1")
req.Body = io.NopCloser(strings.NewReader(body))
rec := httptest.NewRecorder()
handler.CreateKey(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestListTenantKeysServerError(t *testing.T) {
handler := NewHandler(&stubRepository{err: errors.New("db failed")})
req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys")
req.SetPathValue("tenantID", "tenant-1")
rec := httptest.NewRecorder()
handler.ListTenantKeys(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
}
func requestWithPrincipal(method string, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
return req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{
Subject: "profile-1",
Email: "user@example.test",
Role: "authenticated",
}))
}
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 {
profileID string
tenantID string
keyID string
deleteKeyID string
keys []SSHKey
key SSHKey
createResult SSHKey
listFound bool
getFound bool
createFound bool
deleteResult bool
deleteMember bool
err error
}
func (s *stubRepository) List(_ context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) {
s.profileID = profileID
s.tenantID = tenantID
return s.keys, s.listFound, s.err
}
func (s *stubRepository) Get(_ context.Context, profileID string, keyID string) (SSHKey, bool, error) {
s.profileID = profileID
s.keyID = keyID
return s.key, s.getFound, s.err
}
func (s *stubRepository) Create(_ context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) {
s.profileID = profileID
s.tenantID = tenantID
return s.createResult, s.createFound, s.err
}
func (s *stubRepository) Delete(_ context.Context, profileID string, keyID string) (bool, bool, error) {
s.profileID = profileID
s.deleteKeyID = keyID
return s.deleteResult, s.deleteMember, s.err
}
+172
View File
@@ -0,0 +1,172 @@
package sshkey
import (
"context"
"database/sql"
"errors"
)
type SQLRepository struct {
db *sql.DB
}
func NewSQLRepository(db *sql.DB) SQLRepository {
return SQLRepository{db: db}
}
func (r SQLRepository) List(ctx context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) {
rows, err := r.db.QueryContext(ctx, `
select
sk.id::text,
sk.tenant_id::text,
sk.name,
sk.public_key,
sk.created_at
from public.tenants t
join public.memberships m
on m.tenant_id = t.id
and m.profile_id = $1
join public.ssh_keys sk
on sk.tenant_id = t.id
where t.id = $2
order by sk.created_at desc, sk.name asc
`, profileID, tenantID)
if err != nil {
return nil, false, err
}
defer rows.Close()
var keys []SSHKey
for rows.Next() {
var k SSHKey
if err := rows.Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt); err != nil {
return nil, false, err
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
if len(keys) == 0 {
exists, err := r.tenantExists(ctx, profileID, tenantID)
if err != nil {
return nil, false, err
}
return keys, exists, nil
}
return keys, true, nil
}
func (r SQLRepository) Get(ctx context.Context, profileID string, keyID string) (SSHKey, bool, error) {
var k SSHKey
err := r.db.QueryRowContext(ctx, `
select
sk.id::text,
sk.tenant_id::text,
sk.name,
sk.public_key,
sk.created_at
from public.ssh_keys sk
join public.memberships m
on m.tenant_id = sk.tenant_id
and m.profile_id = $1
where sk.id = $2
`, profileID, keyID).Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return SSHKey{}, false, nil
}
if err != nil {
return SSHKey{}, false, err
}
return k, true, nil
}
func (r SQLRepository) Create(ctx context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) {
exists, err := r.tenantMemberExists(ctx, profileID, tenantID)
if err != nil {
return SSHKey{}, false, err
}
if !exists {
return SSHKey{}, false, nil
}
var k SSHKey
err = r.db.QueryRowContext(ctx, `
insert into public.ssh_keys (tenant_id, name, public_key)
values ($1, $2, $3)
returning id::text, tenant_id::text, name, public_key, created_at
`, tenantID, name, publicKey).Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt)
if err != nil {
return SSHKey{}, false, err
}
return k, true, nil
}
func (r SQLRepository) Delete(ctx context.Context, profileID string, keyID string) (bool, bool, error) {
var tenantID string
err := r.db.QueryRowContext(ctx, `
select sk.tenant_id::text
from public.ssh_keys sk
where sk.id = $1
`, keyID).Scan(&tenantID)
if errors.Is(err, sql.ErrNoRows) {
return false, false, nil
}
if err != nil {
return false, false, err
}
member, err := r.tenantMemberExists(ctx, profileID, tenantID)
if err != nil {
return false, false, err
}
if !member {
return false, true, nil
}
res, err := r.db.ExecContext(ctx, `
delete from public.ssh_keys
where id = $1
`, keyID)
if err != nil {
return false, false, err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return false, false, err
}
return rowsAffected > 0, true, nil
}
func (r SQLRepository) tenantExists(ctx context.Context, profileID string, tenantID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
select exists(
select 1
from public.tenants t
join public.memberships m
on m.tenant_id = t.id
and m.profile_id = $1
where t.id = $2
)
`, profileID, tenantID).Scan(&exists)
return exists, err
}
func (r SQLRepository) tenantMemberExists(ctx context.Context, profileID string, tenantID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
select exists(
select 1
from public.memberships
where profile_id = $1
and tenant_id = $2
)
`, profileID, tenantID).Scan(&exists)
return exists, err
}
+22
View File
@@ -0,0 +1,22 @@
package sshkey
import "context"
type PublicKeyResolver struct {
repository Repository
}
func NewPublicKeyResolver(repository Repository) PublicKeyResolver {
return PublicKeyResolver{repository: repository}
}
func (r PublicKeyResolver) GetPublicKey(ctx context.Context, profileID string, keyID string) (string, bool, error) {
key, found, err := r.repository.Get(ctx, profileID, keyID)
if err != nil {
return "", false, err
}
if !found {
return "", false, nil
}
return key.PublicKey, true, nil
}