feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user