Files
rdp-proxy/backend/internal/platform/secrets/resource_secret_store.go
T
2026-04-28 22:29:50 +03:00

260 lines
7.7 KiB
Go

package secrets
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
postgresplatform "github.com/example/remote-access-platform/backend/internal/platform/postgres"
)
var (
ErrResourceSecretNotFound = errors.New("resource secret not found")
ErrSecretAccessDenied = errors.New("resource secret access denied")
ErrSecretLeaseRequired = errors.New("resource secret resolution requires lease proof")
)
type ResourceSecretStore struct {
db postgresplatform.DBTX
encryptor *Encryptor
now func() time.Time
}
type ResourceSecretResolver interface {
ResolveForSession(ctx context.Context, req ResolveResourceSecretRequest) (*ResolvedResourceSecret, error)
}
type ResourceSecretDescriptor struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
ResourceID string `json:"resource_id"`
SecretRef string `json:"secret_ref"`
Protocol string `json:"protocol"`
Version int `json:"version"`
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
Metadata json.RawMessage `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
RotatedAt *time.Time `json:"rotated_at,omitempty"`
}
type UpsertResourceSecretCommand struct {
OrganizationID string
ResourceID string
Protocol string
SecretRef string
Payload json.RawMessage
Metadata json.RawMessage
ActorUserID string
}
type ResolveResourceSecretRequest struct {
SecretRef string
OrganizationID string
ResourceID string
SessionID string
WorkerID string
LeaseID string
}
type ResolvedResourceSecret struct {
Descriptor ResourceSecretDescriptor
Payload json.RawMessage
}
func NewResourceSecretStore(db postgresplatform.DBTX, encryptor *Encryptor) *ResourceSecretStore {
return &ResourceSecretStore{db: db, encryptor: encryptor, now: time.Now}
}
func (s *ResourceSecretStore) WithDB(db postgresplatform.DBTX) *ResourceSecretStore {
if s == nil {
return nil
}
return &ResourceSecretStore{db: db, encryptor: s.encryptor, now: s.now}
}
func DefaultResourceSecretRef(organizationID, resourceID string) string {
return "rap-secret://org/" + strings.TrimSpace(organizationID) + "/resources/" + strings.TrimSpace(resourceID) + "/primary"
}
func (s *ResourceSecretStore) Upsert(ctx context.Context, cmd UpsertResourceSecretCommand) (*ResourceSecretDescriptor, error) {
if s == nil || s.encryptor == nil {
return nil, ErrSecretEncryptionKeyMissing
}
payload, err := normalizeJSONObject(cmd.Payload)
if err != nil {
return nil, err
}
metadata, err := normalizeJSONObjectAllowEmpty(cmd.Metadata)
if err != nil {
return nil, err
}
secretRef := strings.TrimSpace(cmd.SecretRef)
if secretRef == "" {
secretRef = DefaultResourceSecretRef(cmd.OrganizationID, cmd.ResourceID)
}
protocol := strings.ToLower(strings.TrimSpace(cmd.Protocol))
encrypted, err := s.encryptor.Encrypt(payload, ResourceSecretAAD(cmd.OrganizationID, cmd.ResourceID, secretRef, protocol))
if err != nil {
return nil, err
}
now := s.now().UTC()
const query = `
INSERT INTO resource_secrets (
organization_id, resource_id, secret_ref, protocol, version, key_id,
algorithm, nonce, ciphertext, payload_sha256, metadata, created_by_user_id,
created_at, rotated_at
) VALUES (
$1::uuid, $2::uuid, $3, $4, 1, $5,
$6, $7, $8, $9, $10::jsonb, NULLIF($11, '')::uuid,
$12, NULL
)
ON CONFLICT (resource_id) DO UPDATE SET
secret_ref = EXCLUDED.secret_ref,
protocol = EXCLUDED.protocol,
version = resource_secrets.version + 1,
key_id = EXCLUDED.key_id,
algorithm = EXCLUDED.algorithm,
nonce = EXCLUDED.nonce,
ciphertext = EXCLUDED.ciphertext,
payload_sha256 = EXCLUDED.payload_sha256,
metadata = EXCLUDED.metadata,
created_by_user_id = EXCLUDED.created_by_user_id,
rotated_at = EXCLUDED.created_at
RETURNING id::text, organization_id::text, resource_id::text, secret_ref,
protocol, version, key_id, algorithm, metadata, created_at, rotated_at
`
var descriptor ResourceSecretDescriptor
if err := s.db.QueryRow(ctx, query,
cmd.OrganizationID,
cmd.ResourceID,
secretRef,
protocol,
encrypted.KeyID,
encrypted.Algorithm,
encrypted.Nonce,
encrypted.Ciphertext,
encrypted.PayloadSHA256,
metadata,
cmd.ActorUserID,
now,
).Scan(
&descriptor.ID,
&descriptor.OrganizationID,
&descriptor.ResourceID,
&descriptor.SecretRef,
&descriptor.Protocol,
&descriptor.Version,
&descriptor.KeyID,
&descriptor.Algorithm,
&descriptor.Metadata,
&descriptor.CreatedAt,
&descriptor.RotatedAt,
); err != nil {
return nil, fmt.Errorf("upsert resource secret: %w", err)
}
return &descriptor, nil
}
func (s *ResourceSecretStore) ResolveForSession(ctx context.Context, req ResolveResourceSecretRequest) (*ResolvedResourceSecret, error) {
if s == nil || s.encryptor == nil {
return nil, ErrSecretEncryptionKeyMissing
}
if strings.TrimSpace(req.LeaseID) == "" {
return nil, ErrSecretLeaseRequired
}
const query = `
SELECT sec.id::text, sec.organization_id::text, sec.resource_id::text, sec.secret_ref,
sec.protocol, sec.version, sec.key_id, sec.algorithm, sec.metadata,
sec.created_at, sec.rotated_at, sec.nonce, sec.ciphertext,
rs.organization_id::text, rs.resource_id::text, COALESCE(rs.worker_id, ''), rs.state
FROM resource_secrets sec
JOIN remote_sessions rs ON rs.resource_id = sec.resource_id
WHERE sec.secret_ref = $1 AND rs.id = $2::uuid
`
var descriptor ResourceSecretDescriptor
var nonce, ciphertext []byte
var sessionOrganizationID, sessionResourceID, sessionWorkerID, sessionState string
if err := s.db.QueryRow(ctx, query, req.SecretRef, req.SessionID).Scan(
&descriptor.ID,
&descriptor.OrganizationID,
&descriptor.ResourceID,
&descriptor.SecretRef,
&descriptor.Protocol,
&descriptor.Version,
&descriptor.KeyID,
&descriptor.Algorithm,
&descriptor.Metadata,
&descriptor.CreatedAt,
&descriptor.RotatedAt,
&nonce,
&ciphertext,
&sessionOrganizationID,
&sessionResourceID,
&sessionWorkerID,
&sessionState,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceSecretNotFound
}
return nil, fmt.Errorf("resolve resource secret: %w", err)
}
if descriptor.OrganizationID != req.OrganizationID ||
descriptor.ResourceID != req.ResourceID ||
sessionOrganizationID != req.OrganizationID ||
sessionResourceID != req.ResourceID ||
sessionWorkerID != req.WorkerID ||
!secretResolvableSessionState(sessionState) {
return nil, ErrSecretAccessDenied
}
plaintext, err := s.encryptor.Decrypt(EncryptedPayload{
Algorithm: descriptor.Algorithm,
KeyID: descriptor.KeyID,
Nonce: nonce,
Ciphertext: ciphertext,
}, ResourceSecretAAD(descriptor.OrganizationID, descriptor.ResourceID, descriptor.SecretRef, descriptor.Protocol))
if err != nil {
return nil, err
}
return &ResolvedResourceSecret{
Descriptor: descriptor,
Payload: json.RawMessage(plaintext),
}, nil
}
func normalizeJSONObject(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 || !json.Valid(raw) {
return nil, ErrSecretPayloadInvalid
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, ErrSecretPayloadInvalid
}
encoded, err := json.Marshal(decoded)
if err != nil {
return nil, err
}
return json.RawMessage(encoded), nil
}
func normalizeJSONObjectAllowEmpty(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return json.RawMessage(`{}`), nil
}
return normalizeJSONObject(raw)
}
func secretResolvableSessionState(state string) bool {
switch state {
case "starting", "active", "reconnecting":
return true
default:
return false
}
}