260 lines
7.7 KiB
Go
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
|
|
}
|
|
}
|