Initial project snapshot
This commit is contained in:
@@ -0,0 +1,639 @@
|
||||
package resource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/authority"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
CertificateVerificationModeStrict = "strict"
|
||||
CertificateVerificationModeIgnore = "ignore"
|
||||
RenderQualityProfileLowBandwidth = "low_bandwidth"
|
||||
RenderQualityProfileBalanced = "balanced"
|
||||
RenderQualityProfileHighQuality = "high_quality"
|
||||
RenderQualityProfileTextPriority = "text_priority"
|
||||
ClipboardModeDisabled = "disabled"
|
||||
ClipboardModeClientToServer = "client_to_server"
|
||||
ClipboardModeServerToClient = "server_to_client"
|
||||
ClipboardModeBidirectional = "bidirectional"
|
||||
FileTransferModeDisabled = "disabled"
|
||||
FileTransferModeClientToServer = "client_to_server"
|
||||
FileTransferModeServerToClient = "server_to_client"
|
||||
FileTransferModeBidirectional = "bidirectional"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db *pgxpool.Pool
|
||||
appEnv string
|
||||
secretStore *secrets.ResourceSecretStore
|
||||
authority *authority.Verifier
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
ID string `json:"id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Protocol string `json:"protocol"`
|
||||
SecretRef *string `json:"secret_ref,omitempty"`
|
||||
CertificateVerificationMode string `json:"certificate_verification_mode"`
|
||||
RenderQualityProfile string `json:"render_quality_profile"`
|
||||
ClipboardMode string `json:"clipboard_mode"`
|
||||
FileTransferMode string `json:"file_transfer_mode"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type upsertResourceRequest struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Protocol string `json:"protocol"`
|
||||
SecretRef *string `json:"secret_ref"`
|
||||
CertificateVerificationMode string `json:"certificate_verification_mode"`
|
||||
RenderQualityProfile string `json:"render_quality_profile"`
|
||||
ClipboardMode string `json:"clipboard_mode"`
|
||||
FileTransferMode string `json:"file_transfer_mode"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
type upsertResourceSecretRequest struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
func NewModule(deps module.Dependencies, secretStores ...*secrets.ResourceSecretStore) *Module {
|
||||
var secretStore *secrets.ResourceSecretStore
|
||||
if len(secretStores) > 0 {
|
||||
secretStore = secretStores[0]
|
||||
}
|
||||
authorityVerifier, _ := authority.NewVerifier(deps.Config.Installation)
|
||||
return &Module{db: deps.Infra.DB, appEnv: deps.Config.App.Env, secretStore: secretStore, authority: authorityVerifier}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "resource"
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/resources", func(r chi.Router) {
|
||||
r.Get("/", m.listResources)
|
||||
r.Post("/", m.createResource)
|
||||
r.Get("/{resourceID}", m.getResource)
|
||||
r.Put("/{resourceID}", m.updateResource)
|
||||
r.Put("/{resourceID}/secret", m.upsertResourceSecret)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) listResources(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
orgID := r.URL.Query().Get("organization_id")
|
||||
if userID == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "user_id is required")
|
||||
return
|
||||
}
|
||||
platformRole, err := m.getPlatformRole(r.Context(), userID)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
query := `
|
||||
SELECT r.id, r.organization_id, r.name, r.address, r.protocol, r.secret_ref,
|
||||
r.certificate_verification_mode, r.metadata, r.created_at, r.updated_at,
|
||||
COALESCE(rp.clipboard_mode, 'disabled') AS clipboard_mode,
|
||||
COALESCE(rp.file_transfer_mode, 'disabled') AS file_transfer_mode
|
||||
FROM resources r
|
||||
LEFT JOIN resource_policies rp ON rp.resource_id = r.id
|
||||
`
|
||||
args := make([]any, 0, 2)
|
||||
if platformRole == "platform_admin" || platformRole == "platform_recovery_admin" {
|
||||
if orgID != "" {
|
||||
query += ` WHERE r.organization_id = $1`
|
||||
args = append(args, orgID)
|
||||
}
|
||||
query += ` ORDER BY r.created_at DESC`
|
||||
} else {
|
||||
query += `
|
||||
INNER JOIN organization_memberships om ON om.organization_id = r.organization_id
|
||||
WHERE om.user_id = $1 AND om.status = 'active'
|
||||
`
|
||||
args = append(args, userID)
|
||||
if orgID != "" {
|
||||
query += ` AND r.organization_id = $2`
|
||||
args = append(args, orgID)
|
||||
}
|
||||
query += ` ORDER BY r.created_at DESC`
|
||||
}
|
||||
rows, err := m.db.Query(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
resources := make([]Resource, 0)
|
||||
for rows.Next() {
|
||||
resource, err := scanResource(rows)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"resources": resources})
|
||||
}
|
||||
|
||||
func (m *Module) getResource(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "user_id is required")
|
||||
return
|
||||
}
|
||||
resource, err := m.getByID(r.Context(), chi.URLParam(r, "resourceID"))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "resource not found")
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.ensureResourceAccess(r.Context(), resource.OrganizationID, userID, false); err != nil {
|
||||
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"resource": resource})
|
||||
}
|
||||
|
||||
func (m *Module) createResource(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeUpsertRequest(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := secrets.ValidateResourceSecretReadiness(req.Protocol, req.SecretRef, req.Metadata, m.appEnv); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
resource := Resource{
|
||||
ID: uuid.NewString(),
|
||||
OrganizationID: req.OrganizationID,
|
||||
Name: req.Name,
|
||||
Address: req.Address,
|
||||
Protocol: req.Protocol,
|
||||
SecretRef: req.SecretRef,
|
||||
CertificateVerificationMode: req.CertificateVerificationMode,
|
||||
RenderQualityProfile: req.RenderQualityProfile,
|
||||
ClipboardMode: req.ClipboardMode,
|
||||
FileTransferMode: req.FileTransferMode,
|
||||
Metadata: req.Metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.ensureResourceAccess(r.Context(), req.OrganizationID, req.ActorUserID, true); err != nil {
|
||||
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
tx, err := m.db.Begin(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO resources (
|
||||
id, organization_id, name, address, protocol, secret_ref, certificate_verification_mode, metadata, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)
|
||||
`, resource.ID, resource.OrganizationID, resource.Name, resource.Address, resource.Protocol, resource.SecretRef, resource.CertificateVerificationMode, []byte(resource.Metadata), resource.CreatedAt, resource.UpdatedAt); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := upsertResourcePolicy(r.Context(), tx, resource.ID, resource.ClipboardMode, resource.FileTransferMode, now); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"resource": resource})
|
||||
}
|
||||
|
||||
func (m *Module) updateResource(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeUpsertRequest(r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := secrets.ValidateResourceSecretReadiness(req.Protocol, req.SecretRef, req.Metadata, m.appEnv); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resourceID := chi.URLParam(r, "resourceID")
|
||||
existing, err := m.getByID(r.Context(), resourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "resource not found")
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := m.ensureResourceAccess(r.Context(), existing.OrganizationID, req.ActorUserID, true); err != nil {
|
||||
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if req.OrganizationID != existing.OrganizationID {
|
||||
if err := m.ensureResourceAccess(r.Context(), req.OrganizationID, req.ActorUserID, true); err != nil {
|
||||
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tx, err := m.db.Begin(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
tag, err := tx.Exec(r.Context(), `
|
||||
UPDATE resources
|
||||
SET
|
||||
organization_id = $2,
|
||||
name = $3,
|
||||
address = $4,
|
||||
protocol = $5,
|
||||
secret_ref = $6,
|
||||
certificate_verification_mode = $7,
|
||||
metadata = $8::jsonb,
|
||||
updated_at = $9
|
||||
WHERE id = $1
|
||||
`, resourceID, req.OrganizationID, req.Name, req.Address, req.Protocol, req.SecretRef, req.CertificateVerificationMode, []byte(req.Metadata), now)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
httpx.WriteError(w, http.StatusNotFound, "resource not found")
|
||||
return
|
||||
}
|
||||
if err := upsertResourcePolicy(r.Context(), tx, resourceID, req.ClipboardMode, req.FileTransferMode, now); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resource, err := m.getByID(r.Context(), resourceID)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"resource": resource})
|
||||
}
|
||||
|
||||
func (m *Module) upsertResourceSecret(w http.ResponseWriter, r *http.Request) {
|
||||
if m.secretStore == nil {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "resource secret encryption is not configured")
|
||||
return
|
||||
}
|
||||
var req upsertResourceSecretRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid resource secret payload")
|
||||
return
|
||||
}
|
||||
if req.ActorUserID == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "actor_user_id is required")
|
||||
return
|
||||
}
|
||||
resourceID := chi.URLParam(r, "resourceID")
|
||||
resource, err := m.getByID(r.Context(), resourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "resource not found")
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := m.ensureResourceAccess(r.Context(), resource.OrganizationID, req.ActorUserID, true); err != nil {
|
||||
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
tx, err := m.db.Begin(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
secretStore := m.secretStore.WithDB(tx)
|
||||
secretRef := secrets.DefaultResourceSecretRef(resource.OrganizationID, resource.ID)
|
||||
descriptor, err := secretStore.Upsert(r.Context(), secrets.UpsertResourceSecretCommand{
|
||||
OrganizationID: resource.OrganizationID,
|
||||
ResourceID: resource.ID,
|
||||
Protocol: resource.Protocol,
|
||||
SecretRef: secretRef,
|
||||
Payload: req.Payload,
|
||||
Metadata: req.Metadata,
|
||||
ActorUserID: req.ActorUserID,
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
UPDATE resources
|
||||
SET secret_ref = $2, updated_at = $3
|
||||
WHERE id = $1::uuid
|
||||
`, resource.ID, descriptor.SecretRef, time.Now().UTC()); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := writeAuditEvent(r.Context(), tx, "resource_secret_rotated", req.ActorUserID, "resource_secret", descriptor.SecretRef, map[string]any{
|
||||
"resource_id": resource.ID,
|
||||
"organization_id": resource.OrganizationID,
|
||||
"protocol": resource.Protocol,
|
||||
"version": descriptor.Version,
|
||||
"secret_ref": descriptor.SecretRef,
|
||||
}); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"secret": descriptor})
|
||||
}
|
||||
|
||||
func decodeUpsertRequest(r *http.Request) (*upsertResourceRequest, error) {
|
||||
var req upsertResourceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
if req.Name == "" {
|
||||
return nil, errors.New("name is required")
|
||||
}
|
||||
if req.ActorUserID == "" {
|
||||
return nil, errors.New("actor_user_id is required")
|
||||
}
|
||||
if req.OrganizationID == "" {
|
||||
return nil, errors.New("organization_id is required")
|
||||
}
|
||||
if req.Address == "" {
|
||||
return nil, errors.New("address is required")
|
||||
}
|
||||
if req.Protocol == "" {
|
||||
req.Protocol = "rdp"
|
||||
}
|
||||
mode, err := normalizeCertificateVerificationMode(req.CertificateVerificationMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.CertificateVerificationMode = mode
|
||||
renderQualityProfile, err := normalizeRenderQualityProfile(req.RenderQualityProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.RenderQualityProfile = renderQualityProfile
|
||||
clipboardMode, err := normalizeClipboardMode(req.ClipboardMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.ClipboardMode = clipboardMode
|
||||
fileTransferMode, err := normalizeFileTransferMode(req.FileTransferMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.FileTransferMode = fileTransferMode
|
||||
metadata, err := normalizeMetadata(req.Metadata, req.CertificateVerificationMode, req.RenderQualityProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Metadata = metadata
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func normalizeCertificateVerificationMode(mode string) (string, error) {
|
||||
switch mode {
|
||||
case "", CertificateVerificationModeStrict:
|
||||
return CertificateVerificationModeStrict, nil
|
||||
case CertificateVerificationModeIgnore:
|
||||
return CertificateVerificationModeIgnore, nil
|
||||
default:
|
||||
return "", errors.New("certificate_verification_mode must be one of: strict, ignore")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeClipboardMode(mode string) (string, error) {
|
||||
switch mode {
|
||||
case "", ClipboardModeDisabled:
|
||||
return ClipboardModeDisabled, nil
|
||||
case ClipboardModeClientToServer, ClipboardModeServerToClient, ClipboardModeBidirectional:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", errors.New("clipboard_mode must be one of: disabled, client_to_server, server_to_client, bidirectional")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFileTransferMode(mode string) (string, error) {
|
||||
switch mode {
|
||||
case "", FileTransferModeDisabled:
|
||||
return FileTransferModeDisabled, nil
|
||||
case FileTransferModeClientToServer, FileTransferModeServerToClient, FileTransferModeBidirectional:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", errors.New("file_transfer_mode must be one of: disabled, client_to_server, server_to_client, bidirectional")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMetadata(raw json.RawMessage, certificateVerificationMode, renderQualityProfile string) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage(`{}`)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
return nil, errors.New("metadata must be valid json")
|
||||
}
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return nil, errors.New("metadata must be a json object")
|
||||
}
|
||||
metadata["certificate_verification_mode"] = certificateVerificationMode
|
||||
metadata["render_quality_profile"] = renderQualityProfile
|
||||
encoded, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(encoded), nil
|
||||
}
|
||||
|
||||
func (m *Module) getByID(ctx context.Context, resourceID string) (Resource, error) {
|
||||
row := m.db.QueryRow(ctx, `
|
||||
SELECT r.id, r.organization_id, r.name, r.address, r.protocol, r.secret_ref,
|
||||
r.certificate_verification_mode, r.metadata, r.created_at, r.updated_at,
|
||||
COALESCE(rp.clipboard_mode, 'disabled') AS clipboard_mode,
|
||||
COALESCE(rp.file_transfer_mode, 'disabled') AS file_transfer_mode
|
||||
FROM resources r
|
||||
LEFT JOIN resource_policies rp ON rp.resource_id = r.id
|
||||
WHERE r.id = $1
|
||||
`, resourceID)
|
||||
return scanResource(row)
|
||||
}
|
||||
|
||||
func (m *Module) ensureResourceAccess(ctx context.Context, orgID, userID string, adminRequired bool) error {
|
||||
role, err := m.getPlatformRole(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "platform_admin" || role == "platform_recovery_admin" {
|
||||
return nil
|
||||
}
|
||||
var membershipRole string
|
||||
if err := m.db.QueryRow(ctx, `
|
||||
SELECT role_id
|
||||
FROM organization_memberships
|
||||
WHERE organization_id = $1 AND user_id = $2 AND status = 'active'
|
||||
`, orgID, userID).Scan(&membershipRole); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return errors.New("forbidden")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if adminRequired && membershipRole != "org_owner" && membershipRole != "org_admin" {
|
||||
return errors.New("forbidden")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Module) getPlatformRole(ctx context.Context, userID string) (string, error) {
|
||||
return authority.EffectivePlatformRole(ctx, m.db, m.authority, userID)
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanResource(row rowScanner) (Resource, error) {
|
||||
var resource Resource
|
||||
if err := row.Scan(
|
||||
&resource.ID,
|
||||
&resource.OrganizationID,
|
||||
&resource.Name,
|
||||
&resource.Address,
|
||||
&resource.Protocol,
|
||||
&resource.SecretRef,
|
||||
&resource.CertificateVerificationMode,
|
||||
&resource.Metadata,
|
||||
&resource.CreatedAt,
|
||||
&resource.UpdatedAt,
|
||||
&resource.ClipboardMode,
|
||||
&resource.FileTransferMode,
|
||||
); err != nil {
|
||||
return Resource{}, err
|
||||
}
|
||||
if len(resource.Metadata) == 0 {
|
||||
resource.Metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
if resource.CertificateVerificationMode == "" {
|
||||
resource.CertificateVerificationMode = CertificateVerificationModeStrict
|
||||
}
|
||||
if resource.RenderQualityProfile == "" {
|
||||
resource.RenderQualityProfile = renderQualityProfileFromMetadata(resource.Metadata)
|
||||
}
|
||||
if resource.ClipboardMode == "" {
|
||||
resource.ClipboardMode = ClipboardModeDisabled
|
||||
}
|
||||
if resource.FileTransferMode == "" {
|
||||
resource.FileTransferMode = FileTransferModeDisabled
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func upsertResourcePolicy(ctx context.Context, tx pgx.Tx, resourceID, clipboardMode, fileTransferMode string, now time.Time) error {
|
||||
clipboardEnabled := clipboardMode != ClipboardModeDisabled
|
||||
fileTransferEnabled := fileTransferMode == FileTransferModeClientToServer || fileTransferMode == FileTransferModeBidirectional
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO resource_policies (
|
||||
resource_id, clipboard_enabled, clipboard_mode, file_transfer_enabled, file_transfer_mode, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
ON CONFLICT (resource_id) DO UPDATE SET
|
||||
clipboard_enabled = EXCLUDED.clipboard_enabled,
|
||||
clipboard_mode = EXCLUDED.clipboard_mode,
|
||||
file_transfer_enabled = EXCLUDED.file_transfer_enabled,
|
||||
file_transfer_mode = EXCLUDED.file_transfer_mode,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, resourceID, clipboardEnabled, clipboardMode, fileTransferEnabled, fileTransferMode, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeAuditEvent(ctx context.Context, tx pgx.Tx, eventType, actorUserID, targetType, targetID string, payload map[string]any) error {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO audit_events (
|
||||
id, actor_user_id, event_type, target_type, target_id, payload, created_at
|
||||
) VALUES (
|
||||
$1::uuid, NULLIF($2, '')::uuid, $3, $4, $5, $6::jsonb, $7
|
||||
)
|
||||
`, uuid.NewString(), actorUserID, eventType, targetType, targetID, encoded, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeRenderQualityProfile(profile string) (string, error) {
|
||||
switch profile {
|
||||
case "", RenderQualityProfileBalanced:
|
||||
return RenderQualityProfileBalanced, nil
|
||||
case RenderQualityProfileLowBandwidth, RenderQualityProfileHighQuality, RenderQualityProfileTextPriority:
|
||||
return profile, nil
|
||||
default:
|
||||
return "", errors.New("render_quality_profile must be one of: low_bandwidth, balanced, high_quality, text_priority")
|
||||
}
|
||||
}
|
||||
|
||||
func renderQualityProfileFromMetadata(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return RenderQualityProfileBalanced
|
||||
}
|
||||
var metadata map[string]any
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
return RenderQualityProfileBalanced
|
||||
}
|
||||
if profile, ok := metadata["render_quality_profile"].(string); ok {
|
||||
switch profile {
|
||||
case RenderQualityProfileLowBandwidth, RenderQualityProfileBalanced, RenderQualityProfileHighQuality, RenderQualityProfileTextPriority:
|
||||
return profile
|
||||
}
|
||||
}
|
||||
return RenderQualityProfileBalanced
|
||||
}
|
||||
Reference in New Issue
Block a user