Initial project snapshot
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AssignmentSecretMergeResult struct {
|
||||
Metadata map[string]any
|
||||
Keys []string
|
||||
}
|
||||
|
||||
func MergeResourceSecretIntoAssignmentMetadata(metadata map[string]any, payload json.RawMessage) (AssignmentSecretMergeResult, error) {
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
var secretPayload map[string]any
|
||||
if err := json.Unmarshal(payload, &secretPayload); err != nil {
|
||||
return AssignmentSecretMergeResult{}, fmt.Errorf("decode resolved resource secret: %w", err)
|
||||
}
|
||||
resource, _ := metadata["resource"].(map[string]any)
|
||||
if resource == nil {
|
||||
resource = map[string]any{}
|
||||
metadata["resource"] = resource
|
||||
}
|
||||
resourceMetadata, _ := resource["metadata"].(map[string]any)
|
||||
if resourceMetadata == nil {
|
||||
resourceMetadata = map[string]any{}
|
||||
resource["metadata"] = resourceMetadata
|
||||
}
|
||||
keys := make([]string, 0, len(secretPayload))
|
||||
for key, value := range secretPayload {
|
||||
resourceMetadata[key] = value
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return AssignmentSecretMergeResult{Metadata: metadata, Keys: keys}, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const AlgorithmAES256GCM = "AES-256-GCM"
|
||||
|
||||
var (
|
||||
ErrSecretEncryptionKeyMissing = errors.New("secret encryption key is not configured")
|
||||
ErrSecretPayloadInvalid = errors.New("secret payload must be a json object")
|
||||
)
|
||||
|
||||
type Encryptor struct {
|
||||
aead cipher.AEAD
|
||||
keyID string
|
||||
}
|
||||
|
||||
type EncryptedPayload struct {
|
||||
Algorithm string
|
||||
KeyID string
|
||||
Nonce []byte
|
||||
Ciphertext []byte
|
||||
PayloadSHA256 string
|
||||
}
|
||||
|
||||
func NewEncryptor(masterKeyBase64, keyID string) (*Encryptor, error) {
|
||||
masterKeyBase64 = strings.TrimSpace(masterKeyBase64)
|
||||
if masterKeyBase64 == "" {
|
||||
return nil, ErrSecretEncryptionKeyMissing
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(masterKeyBase64)
|
||||
if err != nil {
|
||||
if rawKey, rawErr := base64.RawStdEncoding.DecodeString(masterKeyBase64); rawErr == nil {
|
||||
key = rawKey
|
||||
} else {
|
||||
return nil, fmt.Errorf("decode secret encryption key: %w", err)
|
||||
}
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("secret encryption key must decode to 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create secret cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create secret gcm: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(keyID) == "" {
|
||||
keyID = "local-v1"
|
||||
}
|
||||
return &Encryptor{aead: aead, keyID: keyID}, nil
|
||||
}
|
||||
|
||||
func (e *Encryptor) KeyID() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.keyID
|
||||
}
|
||||
|
||||
func (e *Encryptor) Encrypt(plaintext, aad []byte) (EncryptedPayload, error) {
|
||||
if e == nil {
|
||||
return EncryptedPayload{}, ErrSecretEncryptionKeyMissing
|
||||
}
|
||||
nonce := make([]byte, e.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return EncryptedPayload{}, fmt.Errorf("generate secret nonce: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(plaintext)
|
||||
return EncryptedPayload{
|
||||
Algorithm: AlgorithmAES256GCM,
|
||||
KeyID: e.keyID,
|
||||
Nonce: nonce,
|
||||
Ciphertext: e.aead.Seal(nil, nonce, plaintext, aad),
|
||||
PayloadSHA256: hex.EncodeToString(hash[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *Encryptor) Decrypt(payload EncryptedPayload, aad []byte) ([]byte, error) {
|
||||
if e == nil {
|
||||
return nil, ErrSecretEncryptionKeyMissing
|
||||
}
|
||||
if payload.Algorithm != "" && payload.Algorithm != AlgorithmAES256GCM {
|
||||
return nil, fmt.Errorf("unsupported secret algorithm %q", payload.Algorithm)
|
||||
}
|
||||
plaintext, err := e.aead.Open(nil, payload.Nonce, payload.Ciphertext, aad)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt secret payload: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func ResourceSecretAAD(organizationID, resourceID, secretRef, protocol string) []byte {
|
||||
return []byte(strings.Join([]string{
|
||||
"rap-resource-secret-v1",
|
||||
strings.TrimSpace(organizationID),
|
||||
strings.TrimSpace(resourceID),
|
||||
strings.TrimSpace(secretRef),
|
||||
strings.ToLower(strings.TrimSpace(protocol)),
|
||||
}, "|"))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptorRoundTrip(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
encryptor, err := NewEncryptor(key, "test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncryptor returned error: %v", err)
|
||||
}
|
||||
aad := ResourceSecretAAD("org-1", "resource-1", "rap-secret://test", "rdp")
|
||||
encrypted, err := encryptor.Encrypt([]byte(`{"username":"user","password":"secret"}`), aad)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt returned error: %v", err)
|
||||
}
|
||||
plaintext, err := encryptor.Decrypt(encrypted, aad)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt returned error: %v", err)
|
||||
}
|
||||
if string(plaintext) != `{"username":"user","password":"secret"}` {
|
||||
t.Fatalf("unexpected plaintext: %s", plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptorRejectsWrongAAD(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
encryptor, err := NewEncryptor(key, "test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncryptor returned error: %v", err)
|
||||
}
|
||||
encrypted, err := encryptor.Encrypt([]byte(`{"password":"secret"}`), ResourceSecretAAD("org-1", "resource-1", "ref", "rdp"))
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt returned error: %v", err)
|
||||
}
|
||||
if _, err := encryptor.Decrypt(encrypted, ResourceSecretAAD("org-2", "resource-1", "ref", "rdp")); err == nil {
|
||||
t.Fatalf("expected decrypt with wrong aad to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeResourceSecretIntoAssignmentMetadata(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
"resource": map[string]any{
|
||||
"id": "resource-1",
|
||||
"metadata": map[string]any{
|
||||
"rdp_host": "host",
|
||||
},
|
||||
},
|
||||
}
|
||||
merged, err := MergeResourceSecretIntoAssignmentMetadata(metadata, json.RawMessage(`{"username":"user","password":"secret","domain":"corp"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("MergeResourceSecretIntoAssignmentMetadata returned error: %v", err)
|
||||
}
|
||||
resource := merged.Metadata["resource"].(map[string]any)
|
||||
resourceMetadata := resource["metadata"].(map[string]any)
|
||||
if resourceMetadata["rdp_host"] != "host" {
|
||||
t.Fatalf("existing metadata was not preserved")
|
||||
}
|
||||
if resourceMetadata["username"] != "user" || resourceMetadata["password"] != "secret" || resourceMetadata["domain"] != "corp" {
|
||||
t.Fatalf("secret payload was not merged: %#v", resourceMetadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlaintextResourceCredentials = errors.New("plaintext resource credentials are not allowed in metadata in production")
|
||||
ErrMissingResourceSecretRef = errors.New("secret_ref is required for this resource protocol in production")
|
||||
)
|
||||
|
||||
var credentialKeyFragments = []string{
|
||||
"accesstoken",
|
||||
"clientsecret",
|
||||
"credential",
|
||||
"credentials",
|
||||
"domain",
|
||||
"password",
|
||||
"privatekey",
|
||||
"refreshtoken",
|
||||
"secret",
|
||||
"secrets",
|
||||
"token",
|
||||
"user",
|
||||
"username",
|
||||
}
|
||||
|
||||
var safeReferenceKeys = []string{
|
||||
"certificateverificationmode",
|
||||
"renderqualityprofile",
|
||||
"secretref",
|
||||
"secretreference",
|
||||
"vaultref",
|
||||
}
|
||||
|
||||
func ValidateResourceSecretReadiness(protocol string, secretRef *string, metadata json.RawMessage, appEnv string) error {
|
||||
if !IsProductionEnv(appEnv) {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths, err := PlaintextCredentialMetadataPaths(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
return fmt.Errorf("%w: %s", ErrPlaintextResourceCredentials, strings.Join(paths, ", "))
|
||||
}
|
||||
if ResourceProtocolRequiresSecretRef(protocol) && (secretRef == nil || strings.TrimSpace(*secretRef) == "") {
|
||||
return ErrMissingResourceSecretRef
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsProductionEnv(appEnv string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(appEnv)) {
|
||||
case "prod", "production":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ResourceProtocolRequiresSecretRef(protocol string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(protocol)) {
|
||||
case "rdp", "vnc", "ssh":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func PlaintextCredentialMetadataPaths(raw json.RawMessage) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, errors.New("metadata must be valid json")
|
||||
}
|
||||
metadata, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("metadata must be a json object")
|
||||
}
|
||||
var paths []string
|
||||
collectCredentialPaths(metadata, "", &paths)
|
||||
sort.Strings(paths)
|
||||
return slices.Compact(paths), nil
|
||||
}
|
||||
|
||||
func collectCredentialPaths(value any, prefix string, paths *[]string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if isCredentialMetadataKey(key) {
|
||||
*paths = append(*paths, path)
|
||||
}
|
||||
collectCredentialPaths(child, path, paths)
|
||||
}
|
||||
case []any:
|
||||
for index, child := range typed {
|
||||
collectCredentialPaths(child, fmt.Sprintf("%s[%d]", prefix, index), paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isCredentialMetadataKey(key string) bool {
|
||||
normalized := normalizeMetadataKey(key)
|
||||
if slices.Contains(safeReferenceKeys, normalized) {
|
||||
return false
|
||||
}
|
||||
for _, fragment := range credentialKeyFragments {
|
||||
if normalized == fragment || strings.HasSuffix(normalized, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeMetadataKey(key string) string {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "")
|
||||
return replacer.Replace(key)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateResourceSecretReadinessAllowsPlaintextInDevelopment(t *testing.T) {
|
||||
metadata := json.RawMessage(`{"username":"m","password":"secret"}`)
|
||||
if err := ValidateResourceSecretReadiness("rdp", nil, metadata, "development"); err != nil {
|
||||
t.Fatalf("development metadata should remain allowed for smoke/dev: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceSecretReadinessRejectsPlaintextCredentialsInProduction(t *testing.T) {
|
||||
metadata := json.RawMessage(`{"rdp_host":"host","credentials":{"username":"m","password":"secret"}}`)
|
||||
err := ValidateResourceSecretReadiness("rdp", stringPtr("vault://org/resource"), metadata, "production")
|
||||
if !errors.Is(err, ErrPlaintextResourceCredentials) {
|
||||
t.Fatalf("expected plaintext credential rejection, got %v", err)
|
||||
}
|
||||
|
||||
paths, err := PlaintextCredentialMetadataPaths(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("metadata paths: %v", err)
|
||||
}
|
||||
for _, expected := range []string{"credentials", "credentials.password", "credentials.username"} {
|
||||
if !slices.Contains(paths, expected) {
|
||||
t.Fatalf("expected sensitive path %q in %v", expected, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceSecretReadinessRequiresSecretRefForProductionRDP(t *testing.T) {
|
||||
metadata := json.RawMessage(`{"rdp_host":"host","rdp_port":3389}`)
|
||||
err := ValidateResourceSecretReadiness("rdp", nil, metadata, "production")
|
||||
if !errors.Is(err, ErrMissingResourceSecretRef) {
|
||||
t.Fatalf("expected missing secret_ref rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateResourceSecretReadinessAllowsProductionSecretRef(t *testing.T) {
|
||||
metadata := json.RawMessage(`{"rdp_host":"host","rdp_port":3389,"secret_ref":"vault://org/resource"}`)
|
||||
if err := ValidateResourceSecretReadiness("rdp", stringPtr("vault://org/resource"), metadata, "production"); err != nil {
|
||||
t.Fatalf("production secret_ref metadata should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user