diff --git a/backend-go/CLAUDE.md b/backend-go/CLAUDE.md index e4b6773344f..02ff3dcb671 100644 --- a/backend-go/CLAUDE.md +++ b/backend-go/CLAUDE.md @@ -44,7 +44,7 @@ internal/ │ │ ├── sqln/ # SQL nesting (GroupRows for LEFT JOIN flattening) │ │ └── pglock/ # PostgreSQL advisory locks │ └── redis/ # Redis client -├── libs/ # crypto, errutil, fn, logutil +├── libs/ # crypto, errutil, fn, logutil, cache, jitter, requestid ├── server/ │ ├── api/ # Endpoint implementations + DI wiring │ │ ├── shared/ # Shared types (Error, ValidationError) + ErrorHandler @@ -54,7 +54,8 @@ internal/ ├── services/ # Shared business logic (auth, permission, kms, ...) └── keystore/ # Redis key-value operations tests/ # Integration tests (external test packages) -├── infra/ # Test infrastructure (testcontainers, helpers) +├── infra/ # Test infrastructure (testcontainers, HTTP client) +│ └── nodejs/ # Node.js backend seed facade (svc.For(t), domain builders) ├── platform/ # Platform service tests │ ├── auth/ # Authentication handler tests │ ├── externalkms/ # External KMS (AWS/GCP) tests @@ -63,10 +64,18 @@ tests/ # Integration tests (external test packages) │ ├── permission/ # Permission system tests │ ├── projects/ # Projects handler tests │ └── ratelimit/ # Rate limiting tests -└── secretmanager/ - └── secrets/ # Secrets API tests (list, get, permissions) +└── secrets/ + └── secrets/ # Secrets API tests (list, get, permissions, cache, v3, service token) ``` +**Test seed data**: create projects/identities/secrets/etc. via the `tests/infra/nodejs` +facade — `api := stack.NodeJS().For(t)`, then domain builders like +`api.Secrets.Create(projectID, env, key, value).Comment("...").Do()`. Parameterized +endpoints are builders (required args in the constructor, optional setters, terminal +`Do()`) so new params don't break call sites. Each domain file co-locates its request/ +response models with its endpoints. The `nodejs` package imports nothing from `infra` +(infra wires it via `nodejs.Start`/`Bootstrap`/`AttachDB`), keeping the dependency acyclic. + **Two tiers:** - `server/api/` — DI wiring + endpoint implementations. Handlers 1:1 with endpoints. - `services/` — Business logic, uses `pg.DB` directly. diff --git a/backend-go/cmd/infisical/main.go b/backend-go/cmd/infisical/main.go index cd4b884b0da..c33be472cbe 100644 --- a/backend-go/cmd/infisical/main.go +++ b/backend-go/cmd/infisical/main.go @@ -21,7 +21,7 @@ import ( "github.com/infisical/api/internal/libs/logutil" "github.com/infisical/api/internal/queue" "github.com/infisical/api/internal/server" - "github.com/infisical/api/internal/server/api" + "github.com/infisical/api/internal/services" ) func main() { @@ -74,7 +74,7 @@ func run(cfg *config.Config, logger *slog.Logger) error { defer errutil.DeferErr(ctx, redisClient.Close, "closing redis") // Initialize KeyStore and Queue. - ks := keystore.NewKeyStore(redisClient) + ks := keystore.NewKeyStore(redisClient, db) queueSvc := queue.NewService(ctx, logger, redisClient) defer errutil.DeferErr(ctx, queueSvc.Close, "closing queue") @@ -110,7 +110,7 @@ func run(cfg *config.Config, logger *slog.Logger) error { hsmSvc = hsmService } - services, cleanup, err := api.NewServices(ctx, &api.Infra{ + infra := &services.Infra{ Logger: logger, Config: cfg, DB: db, @@ -119,7 +119,8 @@ func run(cfg *config.Config, logger *slog.Logger) error { License: licenseSvc, KeyStore: ks, Queue: queueSvc, - }) + } + svc, cleanup, err := services.New(ctx, infra) if err != nil { logger.ErrorContext(ctx, "failed to initialize services", slog.Any("error", err)) return err @@ -127,7 +128,7 @@ func run(cfg *config.Config, logger *slog.Logger) error { defer cleanup() // Create server. - srv := server.NewServer(services, cfg, logger) + srv := server.NewServer(svc, cfg, logger) // Create error channel for signal handling and server errors. // Buffered to prevent blocking if multiple senders (signal, queue, HTTP) fire after first receive. diff --git a/backend-go/internal/keystore/keystore.go b/backend-go/internal/keystore/keystore.go index c083f1e3b9e..771822da140 100644 --- a/backend-go/internal/keystore/keystore.go +++ b/backend-go/internal/keystore/keystore.go @@ -5,11 +5,15 @@ import ( "errors" "time" + "github.com/jackc/pgx/v5" "github.com/redis/go-redis/v9" + + "github.com/infisical/api/internal/database/pg" ) -// KeyStore provides key-value operations backed by Redis. +// KeyStore provides key-value operations backed by Redis and PostgreSQL. type KeyStore interface { + // Redis operations SetItem(ctx context.Context, key string, value string) error GetItem(ctx context.Context, key string) (string, error) SetExpiry(ctx context.Context, key string, expiry time.Duration) (bool, error) @@ -19,62 +23,123 @@ type KeyStore interface { DeleteItems(ctx context.Context, keys []string) (int64, error) IncrementBy(ctx context.Context, key string, value int64) (int64, error) + // IncrementByWithExpiry atomically increments a key and sets its expiry. + // If the key doesn't exist, it's created with value 0 before incrementing. + IncrementByWithExpiry(ctx context.Context, key string, value int64, expiry time.Duration) (int64, error) + + // HashGet returns the value of a field in a hash (HGET). + // Returns empty string if key or field doesn't exist. + HashGet(ctx context.Context, key, field string) (string, error) + + // HashSet sets a field in a hash (HSET). + HashSet(ctx context.Context, key, field, value string) error + // StreamAdd adds an entry to a Redis stream (XADD). // Pass "*" as id to auto-generate the entry ID. StreamAdd(ctx context.Context, stream string, id string, values map[string]string) (string, error) + + // PostgreSQL key_value_store operations + + // PgGetIntItem returns the integer value for a key from the PostgreSQL key_value_store table. + // Returns 0 if key doesn't exist or is expired. + PgGetIntItem(ctx context.Context, key string) (int64, error) } -type redisKeyStore struct { - client redis.UniversalClient +type keyStore struct { + redis redis.UniversalClient + db pg.DB } -func NewKeyStore(client redis.UniversalClient) KeyStore { - return &redisKeyStore{client: client} +func NewKeyStore(redisClient redis.UniversalClient, db pg.DB) KeyStore { + return &keyStore{redis: redisClient, db: db} } -func (k *redisKeyStore) SetItem(ctx context.Context, key, value string) error { - return k.client.Set(ctx, key, value, 0).Err() +func (k *keyStore) SetItem(ctx context.Context, key, value string) error { + return k.redis.Set(ctx, key, value, 0).Err() } -func (k *redisKeyStore) GetItem(ctx context.Context, key string) (string, error) { - val, err := k.client.Get(ctx, key).Result() +func (k *keyStore) GetItem(ctx context.Context, key string) (string, error) { + val, err := k.redis.Get(ctx, key).Result() if errors.Is(err, redis.Nil) { return "", nil } return val, err } -func (k *redisKeyStore) SetExpiry(ctx context.Context, key string, expiry time.Duration) (bool, error) { - return k.client.Expire(ctx, key, expiry).Result() +func (k *keyStore) SetExpiry(ctx context.Context, key string, expiry time.Duration) (bool, error) { + return k.redis.Expire(ctx, key, expiry).Result() } -func (k *redisKeyStore) SetItemWithExpiry(ctx context.Context, key string, expiry time.Duration, value string) error { - return k.client.Set(ctx, key, value, expiry).Err() +func (k *keyStore) SetItemWithExpiry(ctx context.Context, key string, expiry time.Duration, value string) error { + return k.redis.Set(ctx, key, value, expiry).Err() } -func (k *redisKeyStore) SetItemWithExpiryNX(ctx context.Context, key string, expiry time.Duration, value string) (bool, error) { - return k.client.SetNX(ctx, key, value, expiry).Result() +func (k *keyStore) SetItemWithExpiryNX(ctx context.Context, key string, expiry time.Duration, value string) (bool, error) { + return k.redis.SetNX(ctx, key, value, expiry).Result() } -func (k *redisKeyStore) DeleteItem(ctx context.Context, key string) (int64, error) { - return k.client.Del(ctx, key).Result() +func (k *keyStore) DeleteItem(ctx context.Context, key string) (int64, error) { + return k.redis.Del(ctx, key).Result() } -func (k *redisKeyStore) DeleteItems(ctx context.Context, keys []string) (int64, error) { +func (k *keyStore) DeleteItems(ctx context.Context, keys []string) (int64, error) { if len(keys) == 0 { return 0, nil } - return k.client.Del(ctx, keys...).Result() + return k.redis.Del(ctx, keys...).Result() +} + +func (k *keyStore) IncrementBy(ctx context.Context, key string, value int64) (int64, error) { + return k.redis.IncrBy(ctx, key, value).Result() +} + +func (k *keyStore) IncrementByWithExpiry(ctx context.Context, key string, value int64, expiry time.Duration) (int64, error) { + pipe := k.redis.TxPipeline() + incrCmd := pipe.IncrBy(ctx, key, value) + pipe.Expire(ctx, key, expiry) + _, err := pipe.Exec(ctx) + if err != nil { + return 0, err + } + return incrCmd.Val(), nil } -func (k *redisKeyStore) IncrementBy(ctx context.Context, key string, value int64) (int64, error) { - return k.client.IncrBy(ctx, key, value).Result() +func (k *keyStore) HashGet(ctx context.Context, key, field string) (string, error) { + val, err := k.redis.HGet(ctx, key, field).Result() + if errors.Is(err, redis.Nil) { + return "", nil + } + return val, err +} + +func (k *keyStore) HashSet(ctx context.Context, key, field, value string) error { + return k.redis.HSet(ctx, key, field, value).Err() } -func (k *redisKeyStore) StreamAdd(ctx context.Context, stream, id string, values map[string]string) (string, error) { - return k.client.XAdd(ctx, &redis.XAddArgs{ +func (k *keyStore) StreamAdd(ctx context.Context, stream, id string, values map[string]string) (string, error) { + return k.redis.XAdd(ctx, &redis.XAddArgs{ Stream: stream, ID: id, Values: values, }).Result() } + +func (k *keyStore) PgGetIntItem(ctx context.Context, key string) (int64, error) { + var integerValue *int64 + err := k.db.Replica().QueryRow(ctx, ` + SELECT "integerValue" + FROM key_value_store + WHERE key = @key + AND ("expiresAt" IS NULL OR "expiresAt" > NOW()) + `, pgx.NamedArgs{"key": key}).Scan(&integerValue) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + return 0, err + } + if integerValue == nil { + return 0, nil + } + return *integerValue, nil +} diff --git a/backend-go/internal/keystore/memory.go b/backend-go/internal/keystore/memory.go index 5638007ceaa..28637734f21 100644 --- a/backend-go/internal/keystore/memory.go +++ b/backend-go/internal/keystore/memory.go @@ -2,6 +2,7 @@ package keystore import ( "context" + "strconv" "sync" "time" ) @@ -12,20 +13,28 @@ type memoryItem struct { expiresAt time.Time } +// memoryHash holds hash field-value pairs for HGET/HSET operations. +type memoryHash struct { + fields map[string]string + expiresAt time.Time +} + func (m memoryItem) isExpired() bool { return !m.expiresAt.IsZero() && time.Now().After(m.expiresAt) } // MemoryKeyStore is an in-memory implementation of KeyStore for testing. type MemoryKeyStore struct { - mu sync.RWMutex - items map[string]memoryItem + mu sync.RWMutex + items map[string]memoryItem + hashes map[string]*memoryHash } // NewMemoryKeyStore creates a new in-memory keystore. func NewMemoryKeyStore() *MemoryKeyStore { return &MemoryKeyStore{ - items: make(map[string]memoryItem), + items: make(map[string]memoryItem), + hashes: make(map[string]*memoryHash), } } @@ -49,13 +58,21 @@ func (m *MemoryKeyStore) GetItem(_ context.Context, key string) (string, error) func (m *MemoryKeyStore) SetExpiry(_ context.Context, key string, expiry time.Duration) (bool, error) { m.mu.Lock() defer m.mu.Unlock() - item, ok := m.items[key] - if !ok { - return false, nil + + // Check regular items first + if item, ok := m.items[key]; ok { + item.expiresAt = time.Now().Add(expiry) + m.items[key] = item + return true, nil } - item.expiresAt = time.Now().Add(expiry) - m.items[key] = item - return true, nil + + // Check hashes + if hash, ok := m.hashes[key]; ok { + hash.expiresAt = time.Now().Add(expiry) + return true, nil + } + + return false, nil } func (m *MemoryKeyStore) SetItemWithExpiry(_ context.Context, key string, expiry time.Duration, value string) error { @@ -122,34 +139,77 @@ func (m *MemoryKeyStore) IncrementBy(_ context.Context, key string, value int64) return newValue, nil } +func (m *MemoryKeyStore) IncrementByWithExpiry(_ context.Context, key string, value int64, expiry time.Duration) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + + item, ok := m.items[key] + if !ok || item.isExpired() { + m.items[key] = memoryItem{ + value: intToString(value), + expiresAt: time.Now().Add(expiry), + } + return value, nil + } + + current := stringToInt(item.value) + newValue := current + value + item.value = intToString(newValue) + item.expiresAt = time.Now().Add(expiry) + m.items[key] = item + return newValue, nil +} + +func (m *MemoryKeyStore) HashGet(_ context.Context, key, field string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + hash, ok := m.hashes[key] + if !ok || (!hash.expiresAt.IsZero() && time.Now().After(hash.expiresAt)) { + return "", nil + } + return hash.fields[field], nil +} + +func (m *MemoryKeyStore) HashSet(_ context.Context, key, field, value string) error { + m.mu.Lock() + defer m.mu.Unlock() + + hash, ok := m.hashes[key] + if !ok { + hash = &memoryHash{fields: make(map[string]string)} + m.hashes[key] = hash + } + hash.fields[field] = value + return nil +} + func (m *MemoryKeyStore) StreamAdd(_ context.Context, stream, id string, values map[string]string) (string, error) { // Simplified implementation - just return a fake ID return "0-0", nil } +func (m *MemoryKeyStore) PgGetIntItem(_ context.Context, key string) (int64, error) { + m.mu.RLock() + defer m.mu.RUnlock() + item, ok := m.items[key] + if !ok || item.isExpired() { + return 0, nil + } + return stringToInt(item.value), nil +} + func intToString(n int64) string { - return string(rune(n)) + return strconv.FormatInt(n, 10) } func stringToInt(s string) int64 { if s == "" { return 0 } - // Parse as integer string - var result int64 - negative := false - start := 0 - if s[0] == '-' { - negative = true - start = 1 - } - for i := start; i < len(s); i++ { - if s[i] >= '0' && s[i] <= '9' { - result = result*10 + int64(s[i]-'0') - } - } - if negative { - return -result + result, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 } return result } diff --git a/backend-go/internal/libs/cache/hash.go b/backend-go/internal/libs/cache/hash.go new file mode 100644 index 00000000000..e63407c451e --- /dev/null +++ b/backend-go/internal/libs/cache/hash.go @@ -0,0 +1,33 @@ +package cache + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "strings" +) + +// GenerateHash creates a URL-safe base64 SHA256 hash of the JSON-serialized data. +// Port of generateCacheKeyFromData from backend/src/lib/crypto/cache.ts. +func GenerateHash(data any) string { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "" + } + return HashBytes(jsonBytes) +} + +// HashString creates a URL-safe base64 SHA256 hash of a string. +func HashString(s string) string { + return HashBytes([]byte(s)) +} + +// HashBytes creates a URL-safe base64 SHA256 hash of raw bytes. +func HashBytes(data []byte) string { + hash := sha256.Sum256(data) + encoded := base64.StdEncoding.EncodeToString(hash[:]) + encoded = strings.ReplaceAll(encoded, "+", "-") + encoded = strings.ReplaceAll(encoded, "/", "_") + encoded = strings.TrimRight(encoded, "=") + return encoded +} diff --git a/backend-go/internal/libs/jitter/jitter.go b/backend-go/internal/libs/jitter/jitter.go new file mode 100644 index 00000000000..d1c667461e5 --- /dev/null +++ b/backend-go/internal/libs/jitter/jitter.go @@ -0,0 +1,17 @@ +// Package jitter applies random jitter to time durations, used to spread out +// otherwise-synchronized timers (e.g. cache TTLs) and avoid thundering herds. +package jitter + +import ( + "math/rand/v2" + "time" +) + +// amount uniformly drawn from [-offset, +offset), yielding a symmetric +// [delay-offset, delay+offset) spread. Returns delay unchanged when offset <= 0. +func Apply(delay, offset time.Duration) time.Duration { + if offset <= 0 { + return delay + } + return delay + time.Duration(rand.Int64N(2*int64(offset))-int64(offset)) +} diff --git a/backend-go/internal/libs/jitter/jitter_test.go b/backend-go/internal/libs/jitter/jitter_test.go new file mode 100644 index 00000000000..eb3cbf0d30d --- /dev/null +++ b/backend-go/internal/libs/jitter/jitter_test.go @@ -0,0 +1,48 @@ +package jitter + +import ( + "testing" + "time" +) + +func TestApply_NonPositiveOffsetReturnsDelay(t *testing.T) { + delay := 10 * time.Minute + for _, offset := range []time.Duration{0, -1, -5 * time.Minute} { + if got := Apply(delay, offset); got != delay { + t.Errorf("Apply(%v, %v) = %v, want %v unchanged", delay, offset, got, delay) + } + } +} + +func TestApply_WithinBounds(t *testing.T) { + delay := 10 * time.Minute + offset := 2 * time.Minute + lower := delay - offset + upper := delay + offset // exclusive + + for i := 0; i < 10000; i++ { + got := Apply(delay, offset) + if got < lower || got >= upper { + t.Fatalf("Apply(%v, %v) = %v, outside [%v, %v)", delay, offset, got, lower, upper) + } + } +} + +func TestApply_SymmetricSpread(t *testing.T) { + delay := 10 * time.Minute + offset := 2 * time.Minute + + var below, above bool + for i := 0; i < 10000; i++ { + switch got := Apply(delay, offset); { + case got < delay: + below = true + case got > delay: + above = true + } + if below && above { + return + } + } + t.Errorf("expected jitter on both sides of delay, got below=%v above=%v", below, above) +} diff --git a/backend-go/internal/server/api/api.go b/backend-go/internal/server/api/api.go index 20da62cd4df..a24918fbab3 100644 --- a/backend-go/internal/server/api/api.go +++ b/backend-go/internal/server/api/api.go @@ -1,57 +1,45 @@ package api import ( - "context" - "fmt" "log/slog" - "github.com/redis/go-redis/v9" + "github.com/go-chi/chi/v5" - "github.com/infisical/api/internal/config" - "github.com/infisical/api/internal/database/pg" - "github.com/infisical/api/internal/ee/services/license" - "github.com/infisical/api/internal/keystore" - "github.com/infisical/api/internal/queue" - "github.com/infisical/api/internal/services/kms" + "github.com/infisical/api/internal/server/api/shared" + "github.com/infisical/api/internal/services" + "github.com/infisical/api/internal/services/auth/apiauth" ) -// Infra holds the external infrastructure dependencies. -type Infra struct { - Logger *slog.Logger - Config *config.Config - DB pg.DB - Redis redis.UniversalClient - HSM kms.HsmService - License *license.Service - KeyStore keystore.KeyStore - Queue *queue.Service -} +// NewErrorHandler re-exports shared.NewErrorHandler for convenience. +var NewErrorHandler = shared.NewErrorHandler -// Services holds all initialized services for the API. -type Services struct { - Platform *PlatformServices - SecretManager *SecretManagerServices +// Router wraps chi.Router with service dependencies. +type Router struct { + chi.Router + logger *slog.Logger + services *services.Services + auth *apiauth.ApiAuthenticator } -// NewServices creates all services for the API. -// Returns a cleanup function that should be called during graceful shutdown. -func NewServices(ctx context.Context, infra *Infra) (*Services, func(), error) { - platformSvc, err := newPlatformServices(ctx, infra) - if err != nil { - return nil, nil, fmt.Errorf("platform services: %w", err) +// NewRouter creates a new router with all routes registered. +func NewRouter(logger *slog.Logger, svc *services.Services) *Router { + auth := apiauth.NewApiAuthenticator( + logger, + svc.Infra().DB, + svc.Infra().Config.AuthSecret, + svc.Infra().KeyStore, + svc.AssumePrivilege, + NewErrorHandler(logger), + ) + + r := &Router{ + Router: chi.NewRouter(), + logger: logger, + services: svc, + auth: auth, } - secretManagerSvc := newSecretManagerServices(ctx, infra, platformSvc) - - services := &Services{ - Platform: platformSvc, - SecretManager: secretManagerSvc, - } - - cleanup := func() { - platformSvc.KMS.Close() - platformSvc.License.Close() - } + r.registerSecretsRoutes() - return services, cleanup, nil + return r } diff --git a/backend-go/internal/server/api/platform/projects/cfg.yaml b/backend-go/internal/server/api/platform/projects/cfg.yaml deleted file mode 100644 index fb873b78bf3..00000000000 --- a/backend-go/internal/server/api/platform/projects/cfg.yaml +++ /dev/null @@ -1,24 +0,0 @@ -package: projects -skip-prune: true -input: openapi.yml -overlay: - sources: - - ../../shared/overlay.yaml -output: - directory: . - use-single-file: true - filename: gen.go -user-templates: - common.tmpl: ../../templates/common.tmpl -additional-imports: - - alias: apivalidator - package: github.com/infisical/api/internal/server/api/validator -generate: - models: true - handler: - kind: chi - validation: - request: true - response: true - output: - overwrite: false diff --git a/backend-go/internal/server/api/platform/projects/gen.go b/backend-go/internal/server/api/platform/projects/gen.go deleted file mode 100644 index a1a8fcf549f..00000000000 --- a/backend-go/internal/server/api/platform/projects/gen.go +++ /dev/null @@ -1,449 +0,0 @@ -// Code generated by oapi-codegen. DO NOT EDIT. - -package projects - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - - "github.com/doordash-oss/oapi-codegen-dd/v3/pkg/runtime" - "github.com/go-chi/chi/v5" - "github.com/infisical/api/internal/server/api/shared" - apivalidator "github.com/infisical/api/internal/server/api/validator" -) - -// OapiErrorKind represents the type of error that occurred during request processing. -type OapiErrorKind int - -const ( - // OapiErrorKindParse indicates a parameter parsing error (invalid path/query/header parameter). - OapiErrorKindParse OapiErrorKind = iota - - // OapiErrorKindDecode indicates a request body decoding error (invalid JSON, form data, etc.). - OapiErrorKindDecode - - // OapiErrorKindValidation indicates a request validation error (failed schema validation). - OapiErrorKindValidation - - // OapiErrorKindService indicates a service/business logic error returned by the service implementation. - OapiErrorKindService -) - -// OapiHandlerError represents an error that occurred during request handling (parse, decode, validation). -// When no typed error response is configured in the OpenAPI spec, this error type is used. -// Custom error handlers can type-assert to this type to access error details. -type OapiHandlerError struct { - Kind OapiErrorKind - OperationID string - Message string - ParamName string - ParamLocation string -} - -func (e OapiHandlerError) Error() string { - return e.Message -} - -// OapiErrorResponse is the default JSON error response structure used by OapiDefaultErrorHandler. -type OapiErrorResponse struct { - Error string `json:"error"` - OperationID string `json:"operation_id,omitempty"` - ParamName string `json:"param_name,omitempty"` - ParamLocation string `json:"param_location,omitempty"` -} - -// OapiErrorHandler handles errors that occur during request processing. -// Implement this interface to customize error responses, logging, and metrics. -type OapiErrorHandler interface { - // HandleError writes an error response to w with the given status code. - // The err is either an OapiHandlerError (for parse/decode/validation errors) - // or a typed error matching the OpenAPI spec's error response schema. - HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) -} - -// OapiDefaultErrorHandler provides the default error handling behavior. -// It writes JSON error responses. For OapiHandlerError, it uses OapiErrorResponse. -// For typed errors (from OpenAPI spec), it encodes them directly. -type OapiDefaultErrorHandler struct{} - -// HandleError implements OapiErrorHandler with default JSON error responses. -func (h *OapiDefaultErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, statusCode int, err error) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - - if handlerErr, ok := err.(OapiHandlerError); ok { - _ = json.NewEncoder(w).Encode(OapiErrorResponse{ - Error: handlerErr.Message, - OperationID: handlerErr.OperationID, - ParamName: handlerErr.ParamName, - ParamLocation: handlerErr.ParamLocation, - }) - return - } - - // Typed error from OpenAPI spec - encode directly - _ = json.NewEncoder(w).Encode(err) -} - -// ServiceInterface defines the service interface for business logic. -type ServiceInterface interface { - // GetProjectsHealth Health check - GetProjectsHealth(ctx context.Context) (*GetProjectsHealthResponseData, error) - // CreateProject Create a project - CreateProject(ctx context.Context, opts *CreateProjectServiceRequestOptions) (*CreateProjectResponseData, error) -} - -// HTTPAdapter adapts the ServiceInterface to HTTP handlers. -// This struct is generated and should not be modified. -type HTTPAdapter struct { - svc ServiceInterface - errHandler OapiErrorHandler - jsonBodyDecoder runtime.JSONBodyDecoderFunc -} - -// NewHTTPAdapter creates a new HTTPAdapter wrapping the given service. -// If errHandler is nil, OapiDefaultErrorHandler is used. -func NewHTTPAdapter(svc ServiceInterface, errHandler OapiErrorHandler) *HTTPAdapter { - if errHandler == nil { - errHandler = &OapiDefaultErrorHandler{} - } - return &HTTPAdapter{svc: svc, errHandler: errHandler, jsonBodyDecoder: runtime.DecodeJSONBody} -} - -// GetProjectsHealth handles GET /health -func (a *HTTPAdapter) GetProjectsHealth(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Call business logic - resp, err := a.svc.GetProjectsHealth(ctx) - if err != nil { - code := http.StatusInternalServerError - if resp != nil && resp.Status != 0 { - code = resp.Status - } - var errResp *GetProjectsHealthErrorResponse - if errors.As(err, &errResp) { - code = 400 - } - a.errHandler.HandleError(w, r, code, err) - return - } - - // Validate response - if resp != nil && resp.Body != nil { - if v, ok := any(resp.Body).(runtime.Validator); ok { - if err := v.Validate(); err != nil { - a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ - Kind: OapiErrorKindValidation, - OperationID: "GetProjectsHealth", - Message: fmt.Sprintf("response validation failed: %v", err), - }) - return - } - } - } - - // Apply custom headers from response - if resp != nil && resp.Headers != nil { - for k, v := range resp.Headers { - for _, val := range v { - w.Header().Add(k, val) - } - } - } - - // Determine status code - status := 200 - if resp != nil && resp.Status != 0 { - status = resp.Status - } - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - w.WriteHeader(status) - if resp != nil && resp.Body != nil { - _ = json.NewEncoder(w).Encode(resp.Body) - } -} - -// CreateProject handles POST / -func (a *HTTPAdapter) CreateProject(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - opts := &CreateProjectServiceRequestOptions{} - opts.RawRequest = r - - // Parse request body - defer r.Body.Close() - var body CreateProjectBody - if err := a.jsonBodyDecoder(r.Body, &body); err != nil { - a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ - Kind: OapiErrorKindDecode, - OperationID: "CreateProject", - Message: err.Error(), - }) - return - } - opts.Body = &body - // Validate request - if err := opts.Validate(); err != nil { - a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ - Kind: OapiErrorKindValidation, - OperationID: "CreateProject", - Message: err.Error(), - }) - return - } - - // Call business logic - resp, err := a.svc.CreateProject(ctx, opts) - if err != nil { - code := http.StatusInternalServerError - if resp != nil && resp.Status != 0 { - code = resp.Status - } - var errResp *CreateProjectErrorResponse - if errors.As(err, &errResp) { - code = 400 - } - a.errHandler.HandleError(w, r, code, err) - return - } - - // Validate response - if resp != nil && resp.Body != nil { - if v, ok := any(resp.Body).(runtime.Validator); ok { - if err := v.Validate(); err != nil { - a.errHandler.HandleError(w, r, http.StatusInternalServerError, OapiHandlerError{ - Kind: OapiErrorKindValidation, - OperationID: "CreateProject", - Message: fmt.Sprintf("response validation failed: %v", err), - }) - return - } - } - } - - // Apply custom headers from response - if resp != nil && resp.Headers != nil { - for k, v := range resp.Headers { - for _, val := range v { - w.Header().Add(k, val) - } - } - } - - // Determine status code - status := 201 - if resp != nil && resp.Status != 0 { - status = resp.Status - } - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - w.WriteHeader(status) - if resp != nil && resp.Body != nil { - _ = json.NewEncoder(w).Encode(resp.Body) - } -} - -// RouterOption is a function that configures the router. -type RouterOption func(*routerConfig) - -type routerConfig struct { - middlewares []func(http.Handler) http.Handler - errHandler OapiErrorHandler - jsonBodyDecoder runtime.JSONBodyDecoderFunc -} - -// WithMiddleware adds middleware to the router. -func WithMiddleware(mw func(http.Handler) http.Handler) RouterOption { - return func(cfg *routerConfig) { - cfg.middlewares = append(cfg.middlewares, mw) - } -} - -// WithErrorHandler sets a custom error handler for the router. -// If not set, OapiOapiDefaultErrorHandler is used. -func WithErrorHandler(h OapiErrorHandler) RouterOption { - return func(cfg *routerConfig) { - cfg.errHandler = h - } -} - -// WithJSONBodyDecoder sets a custom function for decoding JSON request bodies. -// If not set, runtime.DecodeJSONBody is used. -func WithJSONBodyDecoder(fn runtime.JSONBodyDecoderFunc) RouterOption { - return func(cfg *routerConfig) { - cfg.jsonBodyDecoder = fn - } -} - -// NewRouter creates a new chi.Router with the given service implementation. -func NewRouter(svc ServiceInterface, opts ...RouterOption) chi.Router { - cfg := &routerConfig{} - for _, opt := range opts { - opt(cfg) - } - - r := chi.NewRouter() - for _, mw := range cfg.middlewares { - r.Use(mw) - } - - adapter := NewHTTPAdapter(svc, cfg.errHandler) - if cfg.jsonBodyDecoder != nil { - adapter.jsonBodyDecoder = cfg.jsonBodyDecoder - } - r.Method("GET", "/health", http.HandlerFunc(adapter.GetProjectsHealth)) - r.Method("POST", "/", http.HandlerFunc(adapter.CreateProject)) - - return r -} - -type CreateProjectBody = CreateProjectRequest - -// GetProjectsHealthResponseData wraps the success response with optional headers and status override. -type GetProjectsHealthResponseData struct { - Body *GetProjectsHealthResponse - Headers http.Header - Status int // 0 = use default (200) -} - -// NewGetProjectsHealthResponseData creates a new GetProjectsHealthResponseData with the given body. -func NewGetProjectsHealthResponseData(body *GetProjectsHealthResponse) *GetProjectsHealthResponseData { - return &GetProjectsHealthResponseData{Body: body} -} - -// WithHeaders sets custom headers on the response. -func (r *GetProjectsHealthResponseData) WithHeaders(h http.Header) *GetProjectsHealthResponseData { - r.Headers = h - return r -} - -// WithStatus overrides the default status code. -func (r *GetProjectsHealthResponseData) WithStatus(code int) *GetProjectsHealthResponseData { - r.Status = code - return r -} - -// CreateProjectResponseData wraps the success response with optional headers and status override. -type CreateProjectResponseData struct { - Body *CreateProjectResponseJSON - Headers http.Header - Status int // 0 = use default (201) -} - -// NewCreateProjectResponseData creates a new CreateProjectResponseData with the given body. -func NewCreateProjectResponseData(body *CreateProjectResponseJSON) *CreateProjectResponseData { - return &CreateProjectResponseData{Body: body} -} - -// WithHeaders sets custom headers on the response. -func (r *CreateProjectResponseData) WithHeaders(h http.Header) *CreateProjectResponseData { - r.Headers = h - return r -} - -// WithStatus overrides the default status code. -func (r *CreateProjectResponseData) WithStatus(code int) *CreateProjectResponseData { - r.Status = code - return r -} - -type GetProjectsHealthResponse = GetHealthResponse - -type GetProjectsHealthErrorResponse shared.ValidationError - -func (r GetProjectsHealthErrorResponse) Error() string { - return "unmapped client error" -} - -type GetProjectsHealthErrorResponseJSON shared.Error - -type GetProjectsHealthErrorResponseJSON500 shared.Error - -type CreateProjectResponseJSON = CreateProjectResponse - -type CreateProjectErrorResponse shared.ValidationError - -func (r CreateProjectErrorResponse) Error() string { - return "unmapped client error" -} - -type CreateProjectErrorResponseJSON shared.Error - -type CreateProjectErrorResponseJSON500 shared.Error - -// CreateProjectServiceRequestOptions holds all parameters for the CreateProject operation. -type CreateProjectServiceRequestOptions struct { - Body *CreateProjectBody - // RawRequest provides access to the underlying HTTP request for custom content type handling. - RawRequest *http.Request -} - -// Validate validates all the fields in the options. -func (o *CreateProjectServiceRequestOptions) Validate() error { - var errors runtime.ValidationErrors - - if o.Body != nil { - if v, ok := any(o.Body).(runtime.Validator); ok { - if err := v.Validate(); err != nil { - errors = errors.Append("Body", err) - } - } - } - if len(errors) == 0 { - return nil - } - - return errors -} - -type GetHealthResponse struct { - // Message Health check message - Message string `json:"message" validate:"required"` -} - -func (g GetHealthResponse) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(g)) -} - -type CreateProjectRequest struct { - // Name Project name - Name string `json:"name" validate:"required,min=1"` - - // OrgID Organization ID - OrgID string `json:"orgId" validate:"required"` -} - -func (c CreateProjectRequest) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(c)) -} - -type CreateProjectResponse struct { - // ID Project ID - ID string `json:"id" validate:"required"` - - // Name Project name - Name string `json:"name" validate:"required"` - - // OrgID Organization ID - OrgID string `json:"orgId" validate:"required"` -} - -func (c CreateProjectResponse) Validate() error { - return runtime.ConvertValidatorError(typesValidator.Struct(c)) -} - -type Error = shared.Error - -type ValidationError = shared.ValidationError - -type FieldError = shared.FieldError - -type PaginationMeta = shared.PaginationMeta - -var typesValidator = apivalidator.V diff --git a/backend-go/internal/server/api/platform/projects/handler.go b/backend-go/internal/server/api/platform/projects/handler.go deleted file mode 100644 index d21029c92ca..00000000000 --- a/backend-go/internal/server/api/platform/projects/handler.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:generate go tool oapi-codegen -config cfg.yaml openapi.yml - -package projects - -import ( - "context" - "log/slog" - - "github.com/infisical/api/internal/services/permission" -) - -// Compile-time check that Handler implements ServiceInterface. -var _ ServiceInterface = (*Handler)(nil) - -// PermissionService provides project permission checks. -type PermissionService interface { - GetProjectPermission(ctx context.Context, args *permission.GetProjectPermissionArgs) (*permission.GetProjectPermissionResult, error) -} - -// Handler provides HTTP handlers for projects endpoints. -type Handler struct { - logger *slog.Logger - permission PermissionService -} - -// Deps holds the dependencies for the projects handler. -type Deps struct { - Logger *slog.Logger - Permission PermissionService -} - -// NewHandler creates a new projects handler. -func NewHandler(deps *Deps) *Handler { - return &Handler{ - logger: deps.Logger.With(slog.String("handler", "projects")), - permission: deps.Permission, - } -} - -// GetProjectsHealth handles the health check endpoint. -func (h *Handler) GetProjectsHealth(ctx context.Context) (*GetProjectsHealthResponseData, error) { - h.logger.InfoContext(ctx, "health check") - return NewGetProjectsHealthResponseData(&GetHealthResponse{ - Message: "projects service is healthy", - }), nil -} - -// CreateProject handles the create project endpoint. -func (h *Handler) CreateProject(ctx context.Context, opts *CreateProjectServiceRequestOptions) (*CreateProjectResponseData, error) { - h.logger.InfoContext(ctx, "creating project", slog.String("name", opts.Body.Name)) - return NewCreateProjectResponseData(&CreateProjectResponse{ - ID: "generated-id", - Name: opts.Body.Name, - OrgID: opts.Body.OrgID, - }), nil -} diff --git a/backend-go/internal/server/api/platform/projects/openapi.yml b/backend-go/internal/server/api/platform/projects/openapi.yml deleted file mode 100644 index 88851e7ac57..00000000000 --- a/backend-go/internal/server/api/platform/projects/openapi.yml +++ /dev/null @@ -1,85 +0,0 @@ -openapi: "3.0.0" -info: - version: 1.0.0 - title: Projects API - description: Project management endpoints - -servers: - - url: /api/v1/platform/projects - -paths: - /health: - get: - operationId: getProjectsHealth - summary: Health check - description: Returns health status for the projects service - tags: - - Projects - security: [] - responses: - "200": - description: Health status - content: - application/json: - schema: - $ref: "#/components/schemas/GetHealthResponse" - - /: - post: - operationId: createProject - summary: Create a project - description: Creates a new project in the organization - tags: - - Projects - security: - - jwt: [] - - identityAccessToken: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CreateProjectRequest" - responses: - "201": - description: Project created - content: - application/json: - schema: - $ref: "#/components/schemas/CreateProjectResponse" - -components: - schemas: - GetHealthResponse: - type: object - required: [message] - properties: - message: - type: string - description: Health check message - - CreateProjectRequest: - type: object - required: [name, orgId] - properties: - name: - type: string - minLength: 1 - description: Project name - orgId: - type: string - description: Organization ID - - CreateProjectResponse: - type: object - required: [id, name, orgId] - properties: - id: - type: string - description: Project ID - name: - type: string - description: Project name - orgId: - type: string - description: Organization ID diff --git a/backend-go/internal/server/api/platform_routes.go b/backend-go/internal/server/api/platform_routes.go deleted file mode 100644 index 6ed4c3372fa..00000000000 --- a/backend-go/internal/server/api/platform_routes.go +++ /dev/null @@ -1,40 +0,0 @@ -package api - -import ( - "log/slog" - - "github.com/go-chi/chi/v5" - - "github.com/infisical/api/internal/ee/services/ratelimit" - "github.com/infisical/api/internal/server/api/platform/projects" - "github.com/infisical/api/internal/server/api/shared" - "github.com/infisical/api/internal/services/auth/apiauth" -) - -// RegisterPlatformRoutes initializes platform handlers and registers their routes. -func RegisterPlatformRoutes(router chi.Router, logger *slog.Logger, svc *PlatformServices) { - l := logger.With(slog.String("product", "platform")) - - projectsHandler := projects.NewHandler(&projects.Deps{ - Logger: l, - Permission: svc.Permission, - }) - - // Create adapter with shared error handler - projectsAdapter := projects.NewHTTPAdapter(projectsHandler, shared.NewErrorHandler(l)) - - // Mount projects routes - router.Route("/api/v1/platform/projects", func(r chi.Router) { - // Unauthenticated routes - r.Get("/health", projectsAdapter.GetProjectsHealth) - - // Authenticated routes - r.Group(func(r chi.Router) { - r.Use(svc.ApiAuthenticator.RequireAuth( - apiauth.WithAuthModes(apiauth.JWTAuth, apiauth.IdentityAccessTokenAuth, apiauth.ServiceTokenAuth), - )) - r.Use(svc.RateLimit.Middleware(ratelimit.PresetWrite)) - r.Post("/", projectsAdapter.CreateProject) - }) - }) -} diff --git a/backend-go/internal/server/api/platform_services.go b/backend-go/internal/server/api/platform_services.go deleted file mode 100644 index d8cecad322f..00000000000 --- a/backend-go/internal/server/api/platform_services.go +++ /dev/null @@ -1,96 +0,0 @@ -package api - -import ( - "context" - "fmt" - - "github.com/infisical/api/internal/ee/services/externalkms" - "github.com/infisical/api/internal/ee/services/license" - "github.com/infisical/api/internal/ee/services/ratelimit" - "github.com/infisical/api/internal/server/api/shared" - "github.com/infisical/api/internal/services/assumeprivilege" - "github.com/infisical/api/internal/services/auditlog" - "github.com/infisical/api/internal/services/auth/apiauth" - "github.com/infisical/api/internal/services/kms" - "github.com/infisical/api/internal/services/permission" - "github.com/infisical/api/internal/services/project" -) - -// PlatformServices holds platform-level services shared across handlers. -type PlatformServices struct { - ApiAuthenticator *apiauth.ApiAuthenticator - Permission *permission.Service - KMS *kms.Service - License *license.Service - Project *project.Service - AuditLog *auditlog.Service - AssumePrivilege *assumeprivilege.Service - RateLimit *ratelimit.Service -} - -func newPlatformServices(ctx context.Context, infra *Infra) (*PlatformServices, error) { - externalKmsSvc, err := externalkms.NewService(ctx, infra.Logger, &externalkms.Deps{}) - if err != nil { - return nil, fmt.Errorf("external kms: %w", err) - } - - kmsSvc, err := kms.NewService(ctx, infra.Logger, &kms.Deps{ - DB: infra.DB, - HSM: infra.HSM, - ExternalKms: externalKmsSvc, - Config: infra.Config, - }) - if err != nil { - return nil, fmt.Errorf("kms: %w", err) - } - - err = kmsSvc.Start(ctx, infra.HSM != nil) - if err != nil { - return nil, fmt.Errorf("kms start: %w", err) - } - - permissionSvc := permission.NewService(ctx, infra.Logger, &permission.Deps{DB: infra.DB}) - - projectSvc := project.NewService(ctx, infra.Logger, &project.Deps{DB: infra.DB}) - - assumePrivilegeSvc := assumeprivilege.NewService(ctx, infra.Logger, &assumeprivilege.Deps{ - AuthSecret: infra.Config.AuthSecret, - PermissionService: permissionSvc, - }) - - apiAuthenticator := apiauth.NewApiAuthenticator(infra.Logger, infra.DB, infra.Config.AuthSecret, infra.KeyStore, assumePrivilegeSvc, shared.NewErrorHandler(infra.Logger)) - - auditLogSvc := auditlog.NewService(ctx, infra.Logger, &auditlog.Deps{ - Queue: infra.Queue, - Config: infra.Config, - }) - - auditLogQueueHandler := auditlog.NewQueueHandler(ctx, infra.Logger, &auditlog.QueueHandlerDeps{ - DB: infra.DB, - Project: projectSvc, - License: infra.License, - Config: infra.Config, - KeyStore: infra.KeyStore, - }) - auditLogQueueHandler.Register(infra.Queue) - - rateLimitSvc := ratelimit.NewService(ctx, infra.Logger, &ratelimit.Deps{ - Redis: infra.Redis, - LicenseSvc: infra.License, - IsCloud: infra.Config.IsCloud, - IsEnabled: infra.Config.IsCloud && infra.Config.IsProductionMode, - }) - - svc := &PlatformServices{ - ApiAuthenticator: apiAuthenticator, - Permission: permissionSvc, - KMS: kmsSvc, - License: infra.License, - Project: projectSvc, - AuditLog: auditLogSvc, - AssumePrivilege: assumePrivilegeSvc, - RateLimit: rateLimitSvc, - } - - return svc, nil -} diff --git a/backend-go/internal/server/api/secretmanager/secret/secret.go b/backend-go/internal/server/api/secretmanager/secret/secret.go deleted file mode 100644 index d4207570217..00000000000 --- a/backend-go/internal/server/api/secretmanager/secret/secret.go +++ /dev/null @@ -1,198 +0,0 @@ -//go:generate go tool oapi-codegen -config cfg.yaml openapi.yml - -package secret - -import ( - "context" - "log/slog" - "strings" - - "github.com/google/uuid" - - "github.com/infisical/api/internal/libs/errutil" - "github.com/infisical/api/internal/services/auditlog" - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/internal/services/permission" - secretsvc "github.com/infisical/api/internal/services/secretmanager/secret" -) - -// Compile-time check that Handler implements ServiceInterface. -var _ ServiceInterface = (*Handler)(nil) - -// --- Service Interfaces (consumer-defined) --- - -// PermissionService provides project permission checks. -type PermissionService interface { - GetProjectPermission(ctx context.Context, args *permission.GetProjectPermissionArgs) (*permission.GetProjectPermissionResult, error) -} - -// ProjectService resolves project identifiers. -type ProjectService interface { - ResolveProjectID(ctx context.Context, orgID uuid.UUID, workspaceID, workspaceSlug *string) (string, error) -} - -// AuditLogService creates audit log entries. -type AuditLogService interface { - CreateAuditLog(ctx context.Context, dto *auditlog.CreateAuditLogDTO) error -} - -// SecretsService provides secret management operations. -type SecretsService interface { - ListSecrets(ctx context.Context, opts *secretsvc.ListSecretsOpts) (*secretsvc.ListSecretsResult, error) - GetSecretByName(ctx context.Context, opts *secretsvc.GetSecretByNameOpts) (*secretsvc.GetSecretByNameResult, error) - FetchAbsoluteSecrets(ctx context.Context, refs []secretsvc.AbsoluteSecretRef, opts secretsvc.AbsoluteFetchOpts) []*secretsvc.ProcessedSecret - LoadProjectImports(ctx context.Context, projectID string) (*secretsvc.ImportLookup, error) - FindByFolderIds(ctx context.Context, folderIDs []uuid.UUID, userID *uuid.UUID, filters *secretsvc.FindByFolderIdsFilter) ([]secretsvc.Secret, error) -} - -// --- Handler --- - -// Handler provides HTTP handlers for secrets endpoints. -type Handler struct { - logger *slog.Logger - permission PermissionService - project ProjectService - auditLog AuditLogService - secrets SecretsService -} - -// Deps holds the dependencies for the secrets handler. -type Deps struct { - Logger *slog.Logger - Permission PermissionService - Project ProjectService - AuditLog AuditLogService - Secrets SecretsService -} - -// NewHandler creates a new secrets handler. -func NewHandler(deps *Deps) *Handler { - return &Handler{ - logger: deps.Logger.With(slog.String("handler", "secrets")), - permission: deps.Permission, - project: deps.Project, - auditLog: deps.AuditLog, - secrets: deps.Secrets, - } -} - -// parseTagSlugs parses a comma-separated string of tag slugs into a slice. -func parseTagSlugs(tagSlugsStr *string) []string { - if tagSlugsStr == nil || *tagSlugsStr == "" { - return nil - } - parts := strings.Split(*tagSlugsStr, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - if len(result) == 0 { - return nil - } - return result -} - -// parseMetadataFilter parses a pipe-delimited string of metadata filters. -func parseMetadataFilter(metadataFilterStr *string) []secretsvc.MetadataFilter { - if metadataFilterStr == nil || *metadataFilterStr == "" { - return nil - } - - pairs := strings.Split(*metadataFilterStr, "|") - result := make([]secretsvc.MetadataFilter, 0, len(pairs)) - - for _, pair := range pairs { - entry := secretsvc.MetadataFilter{} - parts := strings.SplitSeq(pair, ",") - - for part := range parts { - kv := strings.SplitN(part, "=", 2) - if len(kv) != 2 { - continue - } - identifier := strings.TrimSpace(strings.ToLower(kv[0])) - value := strings.TrimSpace(kv[1]) - - switch identifier { - case "key": - entry.Key = value - case "value": - entry.Value = value - } - } - - if entry.Key != "" && entry.Value != "" { - result = append(result, entry) - } - } - - if len(result) == 0 { - return nil - } - return result -} - -// getUserID extracts user ID from identity if actor is a user. -func getUserID(identity *auth.Identity) *uuid.UUID { - if identity == nil { - return nil - } - if identity.Actor == auth.ActorTypeUser { - return &identity.ActorID - } - return nil -} - -// getSecretType returns the secret type, defaulting to "shared" and forcing "shared" for non-user actors. -func getSecretType(identity *auth.Identity, requestedType string) string { - if requestedType == "" { - return "shared" - } - if identity == nil { - return "shared" - } - switch identity.Actor { - case auth.ActorTypeIdentity, auth.ActorTypeService: - return "shared" - default: - return requestedType - } -} - -// createGetSecretsAuditLog creates an audit log entry for listing secrets. -// TODO: Re-enable once Go backend is primary - currently disabled to avoid duplicate logs with Node.js -func (h *Handler) CreateGetSecretsAuditLog(ctx context.Context, projectID, env, secretPath string, numberOfSecrets int) error { - identity, err := auth.IdentityFromContext(ctx) - if err != nil { - return errutil.NotFound("Identity not found in context").WithErr(err) - } - - info := auditlog.BuildAuditLogInfo(identity) - if info == nil { - return nil - } - - dto := &auditlog.CreateAuditLogDTO{ - Event: auditlog.Event{ - Metadata: auditlog.GetSecretsEventMetadata{ - Environment: env, - SecretPath: secretPath, - NumberOfSecrets: numberOfSecrets, - }, - }, - Actor: info.Actor, - ProjectID: &projectID, - IPAddress: info.IPAddress, - UserAgent: info.UserAgent, - UserAgentType: info.UserAgentType, - } - - if err := h.auditLog.CreateAuditLog(ctx, dto); err != nil { - return errutil.InternalServer("Failed to create audit log").WithErrf("createGetSecretsAuditLog: %w", err) - } - - return nil -} diff --git a/backend-go/internal/server/api/secretmanager_routes.go b/backend-go/internal/server/api/secretmanager_routes.go deleted file mode 100644 index b89394c392d..00000000000 --- a/backend-go/internal/server/api/secretmanager_routes.go +++ /dev/null @@ -1,44 +0,0 @@ -package api - -import ( - "log/slog" - - "github.com/go-chi/chi/v5" - - "github.com/infisical/api/internal/ee/services/ratelimit" - "github.com/infisical/api/internal/server/api/secretmanager/secret" - "github.com/infisical/api/internal/server/api/shared" - "github.com/infisical/api/internal/services/auth/apiauth" -) - -// RegisterSecretManagerRoutes initializes secret manager handlers and registers their routes. -func RegisterSecretManagerRoutes(router chi.Router, logger *slog.Logger, platform *PlatformServices, svc *SecretManagerServices) { - l := logger.With(slog.String("product", "secretmanager")) - - secretsHandler := secret.NewHandler(&secret.Deps{ - Logger: l, - Permission: platform.Permission, - Project: platform.Project, - AuditLog: platform.AuditLog, - Secrets: svc.Secret, - }) - - // Create adapter with shared error handler - secretsAdapter := secret.NewHTTPAdapter(secretsHandler, shared.NewErrorHandler(l)) - - // Secrets routes - all require authentication - router.Group(func(r chi.Router) { - r.Use(platform.ApiAuthenticator.RequireAuth( - apiauth.WithAuthModes(apiauth.JWTAuth, apiauth.IdentityAccessTokenAuth, apiauth.ServiceTokenAuth), - )) - r.Use(platform.RateLimit.Middleware(ratelimit.PresetSecrets)) - - // V4 endpoints - r.Get("/api/v4/secrets", secretsAdapter.ListSecretsV4) - r.Get("/api/v4/secrets/{secretName}", secretsAdapter.GetSecretByNameV4) - - // V3 endpoints (deprecated) - r.Get("/api/v3/secrets/raw", secretsAdapter.ListSecretsRawV3) - r.Get("/api/v3/secrets/raw/{secretName}", secretsAdapter.GetSecretByNameRawV3) - }) -} diff --git a/backend-go/internal/server/api/secretmanager_services.go b/backend-go/internal/server/api/secretmanager_services.go deleted file mode 100644 index e93042498dc..00000000000 --- a/backend-go/internal/server/api/secretmanager_services.go +++ /dev/null @@ -1,40 +0,0 @@ -package api - -import ( - "context" - - "github.com/infisical/api/internal/services/secretmanager/environment" - "github.com/infisical/api/internal/services/secretmanager/secret" - "github.com/infisical/api/internal/services/secretmanager/secretfolder" - "github.com/infisical/api/internal/services/secretmanager/secretimport" -) - -// SecretManagerServices holds secret manager services shared across handlers. -type SecretManagerServices struct { - SecretFolder *secretfolder.Service - SecretImport *secretimport.Service - Environment *environment.Service - Secret *secret.Service -} - -func newSecretManagerServices(ctx context.Context, infra *Infra, platform *PlatformServices) *SecretManagerServices { - secretFolderSvc := secretfolder.NewService(ctx, infra.Logger, &secretfolder.Deps{DB: infra.DB}) - secretImportSvc := secretimport.NewService(ctx, infra.Logger, &secretimport.Deps{DB: infra.DB}) - environmentSvc := environment.NewService(ctx, infra.Logger, &environment.Deps{DB: infra.DB}) - - secretSvc := secret.NewService(ctx, infra.Logger, &secret.Deps{ - DB: infra.DB, - SecretFolderService: secretFolderSvc, - SecretImportService: secretImportSvc, - KMSService: platform.KMS, - }) - - svc := &SecretManagerServices{ - SecretFolder: secretFolderSvc, - SecretImport: secretImportSvc, - Environment: environmentSvc, - Secret: secretSvc, - } - - return svc -} diff --git a/backend-go/internal/server/api/secretmanager/secret/cfg.yaml b/backend-go/internal/server/api/secrets/secret/cfg.yaml similarity index 69% rename from backend-go/internal/server/api/secretmanager/secret/cfg.yaml rename to backend-go/internal/server/api/secrets/secret/cfg.yaml index 4e97acc8576..ad40885713b 100644 --- a/backend-go/internal/server/api/secretmanager/secret/cfg.yaml +++ b/backend-go/internal/server/api/secrets/secret/cfg.yaml @@ -1,6 +1,6 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/doordash-oss/oapi-codegen-dd/0479d1d804b0e22dcada175161c70cd9efaf7733/configuration-schema.json package: secret skip-prune: true -input: openapi.yml overlay: sources: - ../../shared/overlay.yaml @@ -10,6 +10,7 @@ output: filename: gen.go user-templates: common.tmpl: ../../templates/common.tmpl + chi.tmpl: ../../templates/chi.tmpl additional-imports: - alias: apivalidator package: github.com/infisical/api/internal/server/api/validator diff --git a/backend-go/internal/server/api/secretmanager/secret/gen.go b/backend-go/internal/server/api/secrets/secret/gen.go similarity index 95% rename from backend-go/internal/server/api/secretmanager/secret/gen.go rename to backend-go/internal/server/api/secrets/secret/gen.go index 146b1f956f8..24703758d94 100644 --- a/backend-go/internal/server/api/secretmanager/secret/gen.go +++ b/backend-go/internal/server/api/secrets/secret/gen.go @@ -285,6 +285,15 @@ func (a *HTTPAdapter) ListSecretsV4(w http.ResponseWriter, r *http.Request) { queryParams.MetadataFilter = &queryParamMetadataFilter } opts.Query = queryParams + + // Parse header parameters + headerParams := &ListSecretsV4Headers{} + headers := r.Header + if headerValues := headers[http.CanonicalHeaderKey("If-None-Match")]; len(headerValues) > 0 { + headerParamIfNoneMatch := headerValues[0] + headerParams.IfNoneMatch = &headerParamIfNoneMatch + } + opts.Header = headerParams // Validate request if err := opts.Validate(); err != nil { a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ @@ -302,10 +311,6 @@ func (a *HTTPAdapter) ListSecretsV4(w http.ResponseWriter, r *http.Request) { if resp != nil && resp.Status != 0 { code = resp.Status } - var errResp *ListSecretsV4ErrorResponse - if errors.As(err, &errResp) { - code = 400 - } a.errHandler.HandleError(w, r, code, err) return } @@ -587,6 +592,15 @@ func (a *HTTPAdapter) ListSecretsRawV3(w http.ResponseWriter, r *http.Request) { queryParams.MetadataFilter = &queryParamMetadataFilter } opts.Query = queryParams + + // Parse header parameters + headerParams := &ListSecretsRawV3Headers{} + headers := r.Header + if headerValues := headers[http.CanonicalHeaderKey("If-None-Match")]; len(headerValues) > 0 { + headerParamIfNoneMatch := headerValues[0] + headerParams.IfNoneMatch = &headerParamIfNoneMatch + } + opts.Header = headerParams // Validate request if err := opts.Validate(); err != nil { a.errHandler.HandleError(w, r, http.StatusBadRequest, OapiHandlerError{ @@ -604,10 +618,6 @@ func (a *HTTPAdapter) ListSecretsRawV3(w http.ResponseWriter, r *http.Request) { if resp != nil && resp.Status != 0 { code = resp.Status } - var errResp *ListSecretsRawV3ErrorResponse - if errors.As(err, &errResp) { - code = 400 - } a.errHandler.HandleError(w, r, code, err) return } @@ -820,7 +830,7 @@ func WithMiddleware(mw func(http.Handler) http.Handler) RouterOption { } // WithErrorHandler sets a custom error handler for the router. -// If not set, OapiOapiDefaultErrorHandler is used. +// If not set, OapiDefaultErrorHandler is used. func WithErrorHandler(h OapiErrorHandler) RouterOption { return func(cfg *routerConfig) { cfg.errHandler = h @@ -837,26 +847,43 @@ func WithJSONBodyDecoder(fn runtime.JSONBodyDecoderFunc) RouterOption { // NewRouter creates a new chi.Router with the given service implementation. func NewRouter(svc ServiceInterface, opts ...RouterOption) chi.Router { + r := chi.NewRouter() + RegisterRoutes(r, svc, opts...) + return r +} + +// RegisterRoutes registers all routes on an existing chi.Router. +// Middlewares are applied within a Group so they only affect these routes. +func RegisterRoutes(r chi.Router, svc ServiceInterface, opts ...RouterOption) { cfg := &routerConfig{} for _, opt := range opts { opt(cfg) } - r := chi.NewRouter() - for _, mw := range cfg.middlewares { - r.Use(mw) - } - adapter := NewHTTPAdapter(svc, cfg.errHandler) if cfg.jsonBodyDecoder != nil { adapter.jsonBodyDecoder = cfg.jsonBodyDecoder } - r.Method("GET", "/api/v4/secrets", http.HandlerFunc(adapter.ListSecretsV4)) - r.Method("GET", "/api/v4/secrets/{secretName}", http.HandlerFunc(adapter.GetSecretByNameV4)) - r.Method("GET", "/api/v3/secrets/raw", http.HandlerFunc(adapter.ListSecretsRawV3)) - r.Method("GET", "/api/v3/secrets/raw/{secretName}", http.HandlerFunc(adapter.GetSecretByNameRawV3)) - return r + r.Group(func(r chi.Router) { + for _, mw := range cfg.middlewares { + r.Use(mw) + } + r.Method("GET", "/api/v4/secrets", http.HandlerFunc(adapter.ListSecretsV4)) + r.Method("GET", "/api/v4/secrets/{secretName}", http.HandlerFunc(adapter.GetSecretByNameV4)) + r.Method("GET", "/api/v3/secrets/raw", http.HandlerFunc(adapter.ListSecretsRawV3)) + r.Method("GET", "/api/v3/secrets/raw/{secretName}", http.HandlerFunc(adapter.GetSecretByNameRawV3)) + }) +} + +type ListSecretsV4Headers struct { + // IfNoneMatch ETag from a previous response. If the resource has not changed, returns 304 Not Modified. + IfNoneMatch *string `json:"If-None-Match,omitempty"` +} + +type ListSecretsRawV3Headers struct { + // IfNoneMatch ETag from a previous response. If the resource has not changed, returns 304 Not Modified. + IfNoneMatch *string `json:"If-None-Match,omitempty"` } type GetSecretByNameV4Path struct { @@ -1062,15 +1089,11 @@ func (r *GetSecretByNameRawV3ResponseData) WithStatus(code int) *GetSecretByName type ListSecretsV4Response struct { Secrets []SecretRaw `json:"secrets" validate:"required"` - Imports []SecretImport `json:"imports,omitempty"` + Imports []SecretImport `json:"imports"` } type ListSecretsV4ErrorResponse shared.ValidationError -func (r ListSecretsV4ErrorResponse) Error() string { - return "unmapped client error" -} - type ListSecretsV4ErrorResponseJSON shared.Error type ListSecretsV4ErrorResponseJSON500 shared.Error @@ -1091,15 +1114,11 @@ type GetSecretByNameV4ErrorResponseJSON500 shared.Error type ListSecretsRawV3Response struct { Secrets []SecretRaw `json:"secrets" validate:"required"` - Imports []SecretImport `json:"imports,omitempty"` + Imports []SecretImport `json:"imports"` } type ListSecretsRawV3ErrorResponse shared.ValidationError -func (r ListSecretsRawV3ErrorResponse) Error() string { - return "unmapped client error" -} - type ListSecretsRawV3ErrorResponseJSON shared.Error type ListSecretsRawV3ErrorResponseJSON500 shared.Error @@ -1120,7 +1139,8 @@ type GetSecretByNameRawV3ErrorResponseJSON500 shared.Error // ListSecretsV4ServiceRequestOptions holds all parameters for the ListSecretsV4 operation. type ListSecretsV4ServiceRequestOptions struct { - Query *ListSecretsV4Query + Query *ListSecretsV4Query + Header *ListSecretsV4Headers // RawRequest provides access to the underlying HTTP request for custom content type handling. RawRequest *http.Request } @@ -1136,6 +1156,14 @@ func (o *ListSecretsV4ServiceRequestOptions) Validate() error { } } } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } if len(errors) == 0 { return nil } @@ -1179,7 +1207,8 @@ func (o *GetSecretByNameV4ServiceRequestOptions) Validate() error { // ListSecretsRawV3ServiceRequestOptions holds all parameters for the ListSecretsRawV3 operation. type ListSecretsRawV3ServiceRequestOptions struct { - Query *ListSecretsRawV3Query + Query *ListSecretsRawV3Query + Header *ListSecretsRawV3Headers // RawRequest provides access to the underlying HTTP request for custom content type handling. RawRequest *http.Request } @@ -1195,6 +1224,14 @@ func (o *ListSecretsRawV3ServiceRequestOptions) Validate() error { } } } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } if len(errors) == 0 { return nil } @@ -1246,18 +1283,18 @@ type SecretRaw struct { SecretKey string `json:"secretKey" validate:"required"` SecretValue string `json:"secretValue" validate:"required"` SecretComment string `json:"secretComment" validate:"required"` - SecretReminderNote *string `json:"secretReminderNote,omitempty"` - SecretReminderRepeatDays *int `json:"secretReminderRepeatDays,omitempty"` + SecretReminderNote *string `json:"secretReminderNote"` + SecretReminderRepeatDays *int `json:"secretReminderRepeatDays"` SkipMultilineEncoding *bool `json:"skipMultilineEncoding,omitempty"` CreatedAt string `json:"createdAt" validate:"required"` UpdatedAt string `json:"updatedAt" validate:"required"` Actor *SecretActor `json:"actor,omitempty"` IsRotatedSecret *bool `json:"isRotatedSecret,omitempty"` - RotationID *string `json:"rotationId,omitempty"` + RotationID *string `json:"rotationId"` SecretPath *string `json:"secretPath,omitempty"` SecretValueHidden bool `json:"secretValueHidden"` - SecretMetadata []ResourceMetadata `json:"secretMetadata,omitempty"` - Tags []SecretTag `json:"tags,omitempty"` + SecretMetadata []ResourceMetadata `json:"secretMetadata"` + Tags []SecretTag `json:"tags"` } func (s SecretRaw) Validate() error { @@ -1359,14 +1396,14 @@ type ImportSecretRaw struct { SecretKey string `json:"secretKey" validate:"required"` SecretValue string `json:"secretValue" validate:"required"` SecretComment string `json:"secretComment" validate:"required"` - SecretReminderNote *string `json:"secretReminderNote,omitempty"` - SecretReminderRepeatDays *int `json:"secretReminderRepeatDays,omitempty"` + SecretReminderNote *string `json:"secretReminderNote"` + SecretReminderRepeatDays *int `json:"secretReminderRepeatDays"` SkipMultilineEncoding *bool `json:"skipMultilineEncoding,omitempty"` Actor *SecretActor `json:"actor,omitempty"` IsRotatedSecret *bool `json:"isRotatedSecret,omitempty"` - RotationID *string `json:"rotationId,omitempty"` + RotationID *string `json:"rotationId"` SecretValueHidden bool `json:"secretValueHidden"` - SecretMetadata []ResourceMetadata `json:"secretMetadata,omitempty"` + SecretMetadata []ResourceMetadata `json:"secretMetadata"` } func (i ImportSecretRaw) Validate() error { diff --git a/backend-go/internal/server/api/secretmanager/secret/get_secret_by_name.go b/backend-go/internal/server/api/secrets/secret/get_secret_by_name.go similarity index 70% rename from backend-go/internal/server/api/secretmanager/secret/get_secret_by_name.go rename to backend-go/internal/server/api/secrets/secret/get_secret_by_name.go index 79f918f79bb..47778c08c1f 100644 --- a/backend-go/internal/server/api/secretmanager/secret/get_secret_by_name.go +++ b/backend-go/internal/server/api/secrets/secret/get_secret_by_name.go @@ -2,17 +2,15 @@ package secret import ( "context" - "log/slog" "github.com/google/uuid" "github.com/infisical/api/internal/libs/errutil" - "github.com/infisical/api/internal/libs/fn" "github.com/infisical/api/internal/services/auditlog" "github.com/infisical/api/internal/services/auth" "github.com/infisical/api/internal/services/permission" permsecretsvc "github.com/infisical/api/internal/services/permission/secretmanager" - secretsvc "github.com/infisical/api/internal/services/secretmanager/secret" + secretsvc "github.com/infisical/api/internal/services/secrets/secret" ) // getSecretByNameInternalOpts are the unified options for getting a secret by name. @@ -26,6 +24,7 @@ type getSecretByNameInternalOpts struct { ViewSecretValue bool ExpandSecretReferences bool IncludeImports bool + Version *int } // getSecretByNameResponse is the internal response type. @@ -64,6 +63,7 @@ func (h *Handler) getSecretByName(ctx context.Context, opts *getSecretByNameInte SecretType: opts.SecretType, UserID: opts.UserID, IncludeImports: opts.IncludeImports, + Version: opts.Version, }) if err != nil { return nil, err @@ -195,99 +195,6 @@ func (h *Handler) getSecretByName(ctx context.Context, opts *getSecretByNameInte return &getSecretByNameResponse{Secret: secretRaw}, nil } -// GetSecretByNameV4 is the handler for getting a secret by name (V4). -func (h *Handler) GetSecretByNameV4(ctx context.Context, opts *GetSecretByNameV4ServiceRequestOptions) (*GetSecretByNameV4ResponseData, error) { - q := opts.Query - p := opts.PathParams - - h.logger.InfoContext(ctx, "getting secret by name v4", - slog.String("projectId", q.ProjectID), - slog.String("environment", q.Environment), - slog.String("secretPath", fn.ValueOr(q.SecretPath, "/")), - slog.String("secretName", p.SecretName), - ) - - identity, err := auth.IdentityFromContext(ctx) - if err != nil { - return nil, err - } - - secretType := "shared" - if q.Type != nil { - secretType = string(*q.Type) - } - - response, err := h.getSecretByName(ctx, &getSecretByNameInternalOpts{ - ProjectID: q.ProjectID, - Environment: q.Environment, - SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), - SecretName: p.SecretName, - SecretType: getSecretType(identity, secretType), - UserID: getUserID(identity), - ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), - ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), - IncludeImports: fn.ValueOr(q.IncludeImports, true), - }) - if err != nil { - return nil, err - } - - return NewGetSecretByNameV4ResponseData(&GetSecretByNameV4Response{ - Secret: response.Secret, - }), nil -} - -// GetSecretByNameRawV3 is the handler for getting a raw secret by name (V3, deprecated). -func (h *Handler) GetSecretByNameRawV3(ctx context.Context, opts *GetSecretByNameRawV3ServiceRequestOptions) (*GetSecretByNameRawV3ResponseData, error) { - q := opts.Query - p := opts.PathParams - - identity, err := auth.IdentityFromContext(ctx) - if err != nil { - return nil, err - } - - projectID, err := h.project.ResolveProjectID(ctx, identity.OrgID, q.WorkspaceID, q.WorkspaceSlug) - if err != nil { - return nil, err - } - - h.logger.InfoContext(ctx, "getting secret by name raw v3", - slog.String("secretName", p.SecretName), - slog.String("projectId", projectID), - slog.String("environment", fn.ValueOr(q.Environment, "")), - ) - - env := fn.ValueOr(q.Environment, "") - if env == "" { - return nil, errutil.BadRequest("Environment is required") - } - - secretType := "shared" - if q.Type != nil { - secretType = string(*q.Type) - } - - response, err := h.getSecretByName(ctx, &getSecretByNameInternalOpts{ - ProjectID: projectID, - Environment: env, - SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), - SecretName: p.SecretName, - SecretType: getSecretType(identity, secretType), - UserID: getUserID(identity), - ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), - ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), - IncludeImports: fn.ValueOr(q.IncludeImports, false), // V3 defaults to false (unlike V4) - }) - if err != nil { - return nil, err - } - - return NewGetSecretByNameRawV3ResponseData(&GetSecretByNameRawV3Response{ - Secret: response.Secret, - }), nil -} - func (h *Handler) CreateGetSecretAuditLog(ctx context.Context, projectID, env, secretPath string, sec *SecretRaw) error { identity, err := auth.IdentityFromContext(ctx) if err != nil { diff --git a/backend-go/internal/server/api/secrets/secret/handler.go b/backend-go/internal/server/api/secrets/secret/handler.go new file mode 100644 index 00000000000..7e38c93624e --- /dev/null +++ b/backend-go/internal/server/api/secrets/secret/handler.go @@ -0,0 +1,326 @@ +//go:generate go tool oapi-codegen -config cfg.yaml openapi.yaml + +package secret + +import ( + "context" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/infisical/api/internal/libs/errutil" + "github.com/infisical/api/internal/libs/fn" + "github.com/infisical/api/internal/services/auditlog" + "github.com/infisical/api/internal/services/auth" + "github.com/infisical/api/internal/services/kms" + "github.com/infisical/api/internal/services/permission" + secretsvc "github.com/infisical/api/internal/services/secrets/secret" + "github.com/infisical/api/internal/services/secrets/secretcache" +) + +// Compile-time check that Handler implements ServiceInterface. +var _ ServiceInterface = (*Handler)(nil) + +// --- Service Interfaces (consumer-defined) --- + +// PermissionService provides project permission checks. +type PermissionService interface { + GetProjectPermission(ctx context.Context, args *permission.GetProjectPermissionArgs) (*permission.GetProjectPermissionResult, error) + GetPermissionFingerprint(ctx context.Context, args *permission.GetPermissionFingerprintArgs) (string, error) +} + +// ProjectService resolves project identifiers. +type ProjectService interface { + ResolveProjectID(ctx context.Context, orgID uuid.UUID, workspaceID, workspaceSlug *string) (string, error) +} + +// AuditLogService creates audit log entries. +type AuditLogService interface { + CreateAuditLog(ctx context.Context, dto *auditlog.CreateAuditLogDTO) error +} + +// SecretsService provides secret management operations. +type SecretsService interface { + ListSecrets(ctx context.Context, opts *secretsvc.ListSecretsOpts) (*secretsvc.ListSecretsResult, error) + GetSecretByName(ctx context.Context, opts *secretsvc.GetSecretByNameOpts) (*secretsvc.GetSecretByNameResult, error) + FetchAbsoluteSecrets(ctx context.Context, refs []secretsvc.AbsoluteSecretRef, opts secretsvc.AbsoluteFetchOpts) []*secretsvc.ProcessedSecret + LoadProjectImports(ctx context.Context, projectID string) (*secretsvc.ImportLookup, error) + FindByFolderIds(ctx context.Context, folderIDs []uuid.UUID, userID *uuid.UUID, filters *secretsvc.FindByFolderIdsFilter) ([]secretsvc.Secret, error) +} + +// KMSService creates cipher pairs for encryption/decryption. +type KMSService interface { + CreateCipherPairWithProjectDataKey(ctx context.Context, projectID string) (*kms.CipherPair, error) +} + +// SecretCacheService provides caching for secrets operations. +type SecretCacheService interface { + CheckListSecrets(ctx context.Context, params *secretcache.ListSecretsCacheParams, cipherPair *kms.CipherPair) (*secretcache.ListSecretsCacheResult, error) + WriteListSecrets(ctx context.Context, params *secretcache.ListSecretsCacheParams, cipherPair *kms.CipherPair, response any) (string, error) +} + +// --- Handler --- + +// Handler provides HTTP handlers for secrets endpoints. +type Handler struct { + logger *slog.Logger + permission PermissionService + project ProjectService + auditLog AuditLogService + secrets SecretsService + kms KMSService + secretCache SecretCacheService +} + +// Deps holds the dependencies for the secrets handler. +type Deps struct { + Logger *slog.Logger + Permission PermissionService + Project ProjectService + AuditLog AuditLogService + Secrets SecretsService + KMS KMSService + SecretCache SecretCacheService +} + +// NewHandler creates a new secrets handler. +func NewHandler(deps *Deps) *Handler { + return &Handler{ + logger: deps.Logger.With(slog.String("handler", "secrets")), + permission: deps.Permission, + project: deps.Project, + auditLog: deps.AuditLog, + secrets: deps.Secrets, + kms: deps.KMS, + secretCache: deps.SecretCache, + } +} + +// --- OpenAPI Spec Methods --- + +// ListSecretsV4 is the handler for listing secrets (V4). +func (h *Handler) ListSecretsV4(ctx context.Context, opts *ListSecretsV4ServiceRequestOptions) (*ListSecretsV4ResponseData, error) { + q := opts.Query + + h.logger.InfoContext(ctx, "listing secrets v4", + slog.String("projectId", q.ProjectID), + slog.String("environment", q.Environment), + slog.String("secretPath", fn.ValueOr(q.SecretPath, "/")), + ) + + identity, err := auth.IdentityFromContext(ctx) + if err != nil { + return nil, err + } + + behavior := PersonalOverridesNeverInclude + if fn.ValueOr(q.IncludePersonalOverrides, false) { + behavior = PersonalOverridesPriority + } + + var ifNoneMatch string + if opts.Header != nil && opts.Header.IfNoneMatch != nil { + ifNoneMatch = *opts.Header.IfNoneMatch + } + + result, err := h.listSecrets(ctx, &listSecretsInternalOpts{ + ProjectID: q.ProjectID, + Environment: q.Environment, + SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), + UserID: getUserID(identity), + Recursive: fn.ValueOr(q.Recursive, false), + ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), + ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), + IncludeImports: fn.ValueOr(q.IncludeImports, true), + PersonalOverridesBehavior: behavior, + TagSlugs: parseTagSlugs(q.TagSlugs), + MetadataFilter: parseMetadataFilter(q.MetadataFilter), + IfNoneMatch: ifNoneMatch, + }) + if err != nil { + return nil, err + } + + if result.NotModified { + resp := NewListSecretsV4ResponseData(&ListSecretsV4Response{ + Secrets: []SecretRaw{}, + }) + resp.Headers = make(http.Header) + resp.Headers.Set("ETag", result.ETag) + return resp.WithStatus(http.StatusNotModified), nil + } + + resp := NewListSecretsV4ResponseData(&ListSecretsV4Response{ + Secrets: result.Response.Secrets, + Imports: result.Response.Imports, + }) + if result.ETag != "" { + resp.Headers = make(http.Header) + resp.Headers.Set("ETag", result.ETag) + } + return resp, nil +} + +// ListSecretsRawV3 is the handler for listing raw secrets (V3, deprecated). +func (h *Handler) ListSecretsRawV3(ctx context.Context, opts *ListSecretsRawV3ServiceRequestOptions) (*ListSecretsRawV3ResponseData, error) { + q := opts.Query + + identity, err := auth.IdentityFromContext(ctx) + if err != nil { + return nil, err + } + + projectID, err := h.project.ResolveProjectID(ctx, identity.OrgID, q.WorkspaceID, q.WorkspaceSlug) + if err != nil { + return nil, err + } + + h.logger.InfoContext(ctx, "listing secrets raw v3", + slog.String("projectId", projectID), + slog.String("environment", fn.ValueOr(q.Environment, "")), + slog.String("secretPath", fn.ValueOr(q.SecretPath, "/")), + ) + + env := fn.ValueOr(q.Environment, "") + if env == "" { + return nil, errutil.BadRequest("Environment is required") + } + + var ifNoneMatch string + if opts.Header != nil && opts.Header.IfNoneMatch != nil { + ifNoneMatch = *opts.Header.IfNoneMatch + } + + result, err := h.listSecrets(ctx, &listSecretsInternalOpts{ + ProjectID: projectID, + Environment: env, + SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), + UserID: getUserID(identity), + Recursive: fn.ValueOr(q.Recursive, false), + ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), + ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), + IncludeImports: fn.ValueOr(q.IncludeImports, false), + PersonalOverridesBehavior: PersonalOverridesIncludeAll, + TagSlugs: parseTagSlugs(q.TagSlugs), + MetadataFilter: parseMetadataFilter(q.MetadataFilter), + IfNoneMatch: ifNoneMatch, + }) + if err != nil { + return nil, err + } + + if result.NotModified { + resp := NewListSecretsRawV3ResponseData(&ListSecretsRawV3Response{ + Secrets: []SecretRaw{}, + }) + resp.Headers = make(http.Header) + resp.Headers.Set("ETag", result.ETag) + return resp.WithStatus(http.StatusNotModified), nil + } + + resp := NewListSecretsRawV3ResponseData(&ListSecretsRawV3Response{ + Secrets: result.Response.Secrets, + Imports: result.Response.Imports, + }) + if result.ETag != "" { + resp.Headers = make(http.Header) + resp.Headers.Set("ETag", result.ETag) + } + return resp, nil +} + +// GetSecretByNameV4 is the handler for getting a secret by name (V4). +func (h *Handler) GetSecretByNameV4(ctx context.Context, opts *GetSecretByNameV4ServiceRequestOptions) (*GetSecretByNameV4ResponseData, error) { + q := opts.Query + p := opts.PathParams + + h.logger.InfoContext(ctx, "getting secret by name v4", + slog.String("projectId", q.ProjectID), + slog.String("environment", q.Environment), + slog.String("secretPath", fn.ValueOr(q.SecretPath, "/")), + slog.String("secretName", p.SecretName), + ) + + identity, err := auth.IdentityFromContext(ctx) + if err != nil { + return nil, err + } + + secretType := "shared" + if q.Type != nil { + secretType = string(*q.Type) + } + + response, err := h.getSecretByName(ctx, &getSecretByNameInternalOpts{ + ProjectID: q.ProjectID, + Environment: q.Environment, + SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), + SecretName: p.SecretName, + SecretType: getSecretType(identity, secretType), + UserID: getUserID(identity), + ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), + ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), + IncludeImports: fn.ValueOr(q.IncludeImports, true), + Version: q.Version, + }) + if err != nil { + return nil, err + } + + return NewGetSecretByNameV4ResponseData(&GetSecretByNameV4Response{ + Secret: response.Secret, + }), nil +} + +// GetSecretByNameRawV3 is the handler for getting a raw secret by name (V3, deprecated). +func (h *Handler) GetSecretByNameRawV3(ctx context.Context, opts *GetSecretByNameRawV3ServiceRequestOptions) (*GetSecretByNameRawV3ResponseData, error) { + q := opts.Query + p := opts.PathParams + + identity, err := auth.IdentityFromContext(ctx) + if err != nil { + return nil, err + } + + projectID, err := h.project.ResolveProjectID(ctx, identity.OrgID, q.WorkspaceID, q.WorkspaceSlug) + if err != nil { + return nil, err + } + + h.logger.InfoContext(ctx, "getting secret by name raw v3", + slog.String("secretName", p.SecretName), + slog.String("projectId", projectID), + slog.String("environment", fn.ValueOr(q.Environment, "")), + ) + + env := fn.ValueOr(q.Environment, "") + if env == "" { + return nil, errutil.BadRequest("Environment is required") + } + + secretType := "shared" + if q.Type != nil { + secretType = string(*q.Type) + } + + response, err := h.getSecretByName(ctx, &getSecretByNameInternalOpts{ + ProjectID: projectID, + Environment: env, + SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), + SecretName: p.SecretName, + SecretType: getSecretType(identity, secretType), + UserID: getUserID(identity), + ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), + ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), + IncludeImports: fn.ValueOr(q.IncludeImports, false), + Version: q.Version, + }) + if err != nil { + return nil, err + } + + return NewGetSecretByNameRawV3ResponseData(&GetSecretByNameRawV3Response{ + Secret: response.Secret, + }), nil +} diff --git a/backend-go/internal/server/api/secretmanager/secret/list_secrets.go b/backend-go/internal/server/api/secrets/secret/list_secrets.go similarity index 65% rename from backend-go/internal/server/api/secretmanager/secret/list_secrets.go rename to backend-go/internal/server/api/secrets/secret/list_secrets.go index eb5d8f81902..968968f2c8c 100644 --- a/backend-go/internal/server/api/secretmanager/secret/list_secrets.go +++ b/backend-go/internal/server/api/secrets/secret/list_secrets.go @@ -2,17 +2,20 @@ package secret import ( "context" + "encoding/json" "log/slog" "sort" + "strings" "github.com/google/uuid" "github.com/infisical/api/internal/libs/errutil" - "github.com/infisical/api/internal/libs/fn" + "github.com/infisical/api/internal/services/auditlog" "github.com/infisical/api/internal/services/auth" "github.com/infisical/api/internal/services/permission" permsecretsvc "github.com/infisical/api/internal/services/permission/secretmanager" - secretsvc "github.com/infisical/api/internal/services/secretmanager/secret" + secretsvc "github.com/infisical/api/internal/services/secrets/secret" + "github.com/infisical/api/internal/services/secrets/secretcache" ) // PersonalOverridesBehavior controls how personal secret overrides are handled. @@ -40,23 +43,43 @@ type listSecretsInternalOpts struct { PersonalOverridesBehavior PersonalOverridesBehavior TagSlugs []string MetadataFilter []secretsvc.MetadataFilter + IfNoneMatch string } // listSecretsResponse is the internal response type for listing secrets. type listSecretsResponse struct { - Secrets []SecretRaw - Imports []SecretImport + Secrets []SecretRaw `json:"secrets"` + Imports []SecretImport `json:"imports,omitempty"` +} + +// listSecretsResponseWithETag wraps the response with caching metadata. +type listSecretsResponseWithETag struct { + Response *listSecretsResponse + ETag string + NotModified bool } // listSecrets is the unified internal method for listing secrets. // Both V3 and V4 handlers call this with different options. -func (h *Handler) listSecrets(ctx context.Context, opts *listSecretsInternalOpts) (*listSecretsResponse, error) { +func (h *Handler) listSecrets(ctx context.Context, opts *listSecretsInternalOpts) (*listSecretsResponseWithETag, error) { identity, err := auth.IdentityFromContext(ctx) if err != nil { return nil, err } - // 1. Get permission + var permissionFingerprint string + if identity.Actor == auth.ActorTypeUser || identity.Actor == auth.ActorTypeIdentity { + permissionFingerprint, err = h.permission.GetPermissionFingerprint(ctx, &permission.GetPermissionFingerprintArgs{ + ProjectID: opts.ProjectID, + OrgID: identity.OrgID, + ActorID: identity.ActorID, + ActorType: identity.Actor, + }) + if err != nil { + h.logger.WarnContext(ctx, "failed to get permission fingerprint, proceeding without cache", slog.Any("error", err)) + } + } + permResult, err := h.permission.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ Actor: identity.Actor, ActorID: identity.ActorID, @@ -70,7 +93,53 @@ func (h *Handler) listSecrets(ctx context.Context, opts *listSecretsInternalOpts } checker := permsecretsvc.NewSecretAccessChecker(permResult.Permission.Ability) - // 2. Fetch ALL secrets (permission-agnostic) + cipherPair, err := h.kms.CreateCipherPairWithProjectDataKey(ctx, opts.ProjectID) + if err != nil { + return nil, err + } + + requestParamsHash := secretcache.BuildListSecretsRequestParamsHash(map[string]any{ + "environment": opts.Environment, + "path": opts.SecretPath, + "recursive": opts.Recursive, + "includeImports": opts.IncludeImports, + "expandSecretReferences": opts.ExpandSecretReferences, + "viewSecretValue": opts.ViewSecretValue, + "personalOverridesBehavior": opts.PersonalOverridesBehavior, + "tagSlugs": opts.TagSlugs, + "metadataFilter": opts.MetadataFilter, + }) + + cacheParams := &secretcache.ListSecretsCacheParams{ + ProjectID: opts.ProjectID, + ActorID: identity.ActorID, + PermissionFingerprint: permissionFingerprint, + PermissionRulesHash: permResult.PermissionRulesHash(), + RequestParamsHash: requestParamsHash, + IfNoneMatch: opts.IfNoneMatch, + } + + cacheResult, err := h.secretCache.CheckListSecrets(ctx, cacheParams, cipherPair) + if err != nil { + h.logger.WarnContext(ctx, "cache check failed, proceeding without cache", slog.Any("error", err)) + } else if cacheResult != nil { + if cacheResult.NotModified { + return &listSecretsResponseWithETag{ + NotModified: true, + ETag: cacheResult.ETag, + }, nil + } + var cached listSecretsResponse + if err := json.Unmarshal(cacheResult.Response, &cached); err != nil { + h.logger.WarnContext(ctx, "failed to unmarshal cached response, proceeding without cache", slog.Any("error", err)) + } else { + return &listSecretsResponseWithETag{ + Response: &cached, + ETag: cacheResult.ETag, + }, nil + } + } + result, err := h.secrets.ListSecrets(ctx, &secretsvc.ListSecretsOpts{ ProjectID: opts.ProjectID, Environment: opts.Environment, @@ -84,7 +153,7 @@ func (h *Handler) listSecrets(ctx context.Context, opts *listSecretsInternalOpts return nil, err } - // 3. Expand references (on permission-filtered pool to prevent leaking unauthorized secrets) + // Expand references (on permission-filtered pool to prevent leaking unauthorized secrets) if opts.ExpandSecretReferences { // Filter the expansion pool to only include secrets the user can read. // This prevents relative reference expansion (e.g., ${RESTRICTED}) from leaking @@ -121,11 +190,9 @@ func (h *Handler) listSecrets(ctx context.Context, opts *listSecretsInternalOpts } } - // 4. Filter by permission + apply ValueHidden result.DirectSecrets = filterListSecretsByPermission(result.DirectSecrets, checker, opts) result.ImportedSecrets = filterListSecretsByPermission(result.ImportedSecrets, checker, opts) - // 5. Filter personal overrides filterPersonalOverrides := func(secrets []secretsvc.ProcessedSecret) []secretsvc.ProcessedSecret { switch opts.PersonalOverridesBehavior { case PersonalOverridesIncludeAll: @@ -163,16 +230,19 @@ func (h *Handler) listSecrets(ctx context.Context, opts *listSecretsInternalOpts result.DirectSecrets = filterPersonalOverrides(result.DirectSecrets) result.ImportedSecrets = filterPersonalOverrides(result.ImportedSecrets) - // 6. Build response response := h.buildListSecretsResponse(result, opts.ProjectID, opts.IncludeImports) + etag, err := h.secretCache.WriteListSecrets(ctx, cacheParams, cipherPair, response) + if err != nil { + h.logger.WarnContext(ctx, "cache write failed", slog.Any("error", err)) + } + // TODO: Re-enable audit logging once Go backend is primary - // // 7. Audit log - // if err := h.createGetSecretsAuditLog(ctx, opts.ProjectID, opts.Environment, opts.SecretPath, len(response.Secrets)); err != nil { - // return nil, err - // } - return response, nil + return &listSecretsResponseWithETag{ + Response: response, + ETag: etag, + }, nil } func filterListSecretsByPermission(secrets []secretsvc.ProcessedSecret, checker *permsecretsvc.SecretAccessChecker, opts *listSecretsInternalOpts) []secretsvc.ProcessedSecret { @@ -204,97 +274,6 @@ func filterListSecretsByPermission(secrets []secretsvc.ProcessedSecret, checker return filtered } -// ListSecretsV4 is the handler for listing secrets (V4). -func (h *Handler) ListSecretsV4(ctx context.Context, opts *ListSecretsV4ServiceRequestOptions) (*ListSecretsV4ResponseData, error) { - q := opts.Query - - h.logger.InfoContext(ctx, "listing secrets v4", - slog.String("projectId", q.ProjectID), - slog.String("environment", q.Environment), - slog.String("secretPath", fn.ValueOr(q.SecretPath, "/")), - ) - - identity, err := auth.IdentityFromContext(ctx) - if err != nil { - return nil, err - } - - behavior := PersonalOverridesNeverInclude - if fn.ValueOr(q.IncludePersonalOverrides, false) { - behavior = PersonalOverridesPriority - } - - response, err := h.listSecrets(ctx, &listSecretsInternalOpts{ - ProjectID: q.ProjectID, - Environment: q.Environment, - SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), - UserID: getUserID(identity), - Recursive: fn.ValueOr(q.Recursive, false), - ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), - ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), - IncludeImports: fn.ValueOr(q.IncludeImports, true), - PersonalOverridesBehavior: behavior, - TagSlugs: parseTagSlugs(q.TagSlugs), - MetadataFilter: parseMetadataFilter(q.MetadataFilter), - }) - if err != nil { - return nil, err - } - - return NewListSecretsV4ResponseData(&ListSecretsV4Response{ - Secrets: response.Secrets, - Imports: response.Imports, - }), nil -} - -// ListSecretsRawV3 is the handler for listing raw secrets (V3, deprecated). -func (h *Handler) ListSecretsRawV3(ctx context.Context, opts *ListSecretsRawV3ServiceRequestOptions) (*ListSecretsRawV3ResponseData, error) { - q := opts.Query - - identity, err := auth.IdentityFromContext(ctx) - if err != nil { - return nil, err - } - - projectID, err := h.project.ResolveProjectID(ctx, identity.OrgID, q.WorkspaceID, q.WorkspaceSlug) - if err != nil { - return nil, err - } - - h.logger.InfoContext(ctx, "listing secrets raw v3", - slog.String("projectId", projectID), - slog.String("environment", fn.ValueOr(q.Environment, "")), - slog.String("secretPath", fn.ValueOr(q.SecretPath, "/")), - ) - - env := fn.ValueOr(q.Environment, "") - if env == "" { - return nil, errutil.BadRequest("Environment is required") - } - - response, err := h.listSecrets(ctx, &listSecretsInternalOpts{ - ProjectID: projectID, - Environment: env, - SecretPath: fn.RemoveTrailingSlash(fn.ValueOr(q.SecretPath, "/")), - UserID: getUserID(identity), - Recursive: fn.ValueOr(q.Recursive, false), - ViewSecretValue: fn.ValueOr(q.ViewSecretValue, true), - ExpandSecretReferences: fn.ValueOr(q.ExpandSecretReferences, true), - IncludeImports: fn.ValueOr(q.IncludeImports, false), // V3 defaults to false (unlike V4) - PersonalOverridesBehavior: PersonalOverridesIncludeAll, // V3 default - TagSlugs: parseTagSlugs(q.TagSlugs), - MetadataFilter: parseMetadataFilter(q.MetadataFilter), - }) - if err != nil { - return nil, err - } - - return NewListSecretsRawV3ResponseData(&ListSecretsRawV3Response{ - Secrets: response.Secrets, - Imports: response.Imports, - }), nil -} - // buildListSecretsResponse builds the response for ListSecretsV4. func (h *Handler) buildListSecretsResponse( result *secretsvc.ListSecretsResult, @@ -366,8 +345,8 @@ func (h *Handler) buildSecretRaw(ps *secretsvc.ProcessedSecret, projectID string SecretValue: ps.Value, SecretComment: ps.Comment, SecretPath: &ps.SecretPath, - CreatedAt: sec.CreatedAt.Format("2006-01-02T15:04:05.000Z"), - UpdatedAt: sec.UpdatedAt.Format("2006-01-02T15:04:05.000Z"), + CreatedAt: sec.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + UpdatedAt: sec.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), SecretValueHidden: ps.ValueHidden, Tags: tags, SecretMetadata: metadata, @@ -471,3 +450,124 @@ func (h *Handler) buildImportsResponse( return imports } + +// CreateListSecretsAuditLog creates an audit log entry for listing secrets. +// TODO: Re-enable once Go backend is primary - currently disabled to avoid duplicate logs with Node.js +func (h *Handler) CreateListSecretsAuditLog(ctx context.Context, projectID, env, secretPath string, numberOfSecrets int) error { + identity, err := auth.IdentityFromContext(ctx) + if err != nil { + return errutil.NotFound("Identity not found in context").WithErr(err) + } + + info := auditlog.BuildAuditLogInfo(identity) + if info == nil { + return nil + } + + dto := &auditlog.CreateAuditLogDTO{ + Event: auditlog.Event{ + Metadata: auditlog.GetSecretsEventMetadata{ + Environment: env, + SecretPath: secretPath, + NumberOfSecrets: numberOfSecrets, + }, + }, + Actor: info.Actor, + ProjectID: &projectID, + IPAddress: info.IPAddress, + UserAgent: info.UserAgent, + UserAgentType: info.UserAgentType, + } + + if err := h.auditLog.CreateAuditLog(ctx, dto); err != nil { + return errutil.InternalServer("Failed to create audit log").WithErrf("createGetSecretsAuditLog: %w", err) + } + + return nil +} + +// parseTagSlugs parses a comma-separated string of tag slugs into a slice. +func parseTagSlugs(tagSlugsStr *string) []string { + if tagSlugsStr == nil || *tagSlugsStr == "" { + return nil + } + parts := strings.Split(*tagSlugsStr, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) == 0 { + return nil + } + return result +} + +// parseMetadataFilter parses a pipe-delimited string of metadata filters. +func parseMetadataFilter(metadataFilterStr *string) []secretsvc.MetadataFilter { + if metadataFilterStr == nil || *metadataFilterStr == "" { + return nil + } + + pairs := strings.Split(*metadataFilterStr, "|") + result := make([]secretsvc.MetadataFilter, 0, len(pairs)) + + for _, pair := range pairs { + entry := secretsvc.MetadataFilter{} + parts := strings.SplitSeq(pair, ",") + + for part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + identifier := strings.TrimSpace(strings.ToLower(kv[0])) + value := strings.TrimSpace(kv[1]) + + switch identifier { + case "key": + entry.Key = value + case "value": + entry.Value = value + } + } + + if entry.Key != "" && entry.Value != "" { + result = append(result, entry) + } + } + + if len(result) == 0 { + return nil + } + return result +} + +// getUserID extracts user ID from identity if actor is a user. +func getUserID(identity *auth.Identity) *uuid.UUID { + if identity == nil { + return nil + } + if identity.Actor == auth.ActorTypeUser { + return &identity.ActorID + } + return nil +} + +// getSecretType returns the secret type, defaulting to "shared" and forcing "shared" for non-user actors. +func getSecretType(identity *auth.Identity, requestedType string) string { + if requestedType == "" { + return "shared" + } + if identity == nil { + return "shared" + } + switch identity.Actor { + case auth.ActorTypeIdentity, auth.ActorTypeService: + return "shared" + default: + return requestedType + } +} diff --git a/backend-go/internal/server/api/secretmanager/secret/openapi.yml b/backend-go/internal/server/api/secrets/secret/openapi.yaml similarity index 84% rename from backend-go/internal/server/api/secretmanager/secret/openapi.yml rename to backend-go/internal/server/api/secrets/secret/openapi.yaml index 4e27ca7aace..a725939caeb 100644 --- a/backend-go/internal/server/api/secretmanager/secret/openapi.yml +++ b/backend-go/internal/server/api/secrets/secret/openapi.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/SchemaStore/schemastore/402fdd800ea211bd1b9bccb1abcda4a2ba20073c/src/schemas/json/openapi-3.X.json openapi: "3.0.0" info: version: 1.0.0 @@ -17,6 +18,11 @@ paths: - identityAccessToken: [] - serviceToken: [] parameters: + - name: If-None-Match + in: header + description: ETag from a previous response. If the resource has not changed, returns 304 Not Modified. + schema: + type: string - name: projectId in: query required: true @@ -68,6 +74,11 @@ paths: responses: '200': description: List of secrets + headers: + ETag: + description: Entity tag for cache validation + schema: + type: string content: application/json: schema: @@ -76,12 +87,21 @@ paths: properties: secrets: type: array + x-omitempty: false items: $ref: '#/components/schemas/SecretRaw' imports: type: array + x-omitempty: false items: $ref: '#/components/schemas/SecretImport' + '304': + description: Not Modified. The resource has not changed since the last request. + headers: + ETag: + description: Entity tag for cache validation + schema: + type: string /api/v4/secrets/{secretName}: get: @@ -165,6 +185,11 @@ paths: - identityAccessToken: [] - serviceToken: [] parameters: + - name: If-None-Match + in: header + description: ETag from a previous response. If the resource has not changed, returns 304 Not Modified. + schema: + type: string - name: workspaceId in: query schema: @@ -213,6 +238,11 @@ paths: responses: '200': description: List of secrets + headers: + ETag: + description: Entity tag for cache validation + schema: + type: string content: application/json: schema: @@ -221,12 +251,21 @@ paths: properties: secrets: type: array + x-omitempty: false items: $ref: '#/components/schemas/SecretRaw' imports: type: array + x-omitempty: false items: $ref: '#/components/schemas/SecretImport' + '304': + description: Not Modified. The resource has not changed since the last request. + headers: + ETag: + description: Entity tag for cache validation + schema: + type: string /api/v3/secrets/raw/{secretName}: get: @@ -327,8 +366,12 @@ components: type: string secretReminderNote: type: string + nullable: true + x-omitempty: false secretReminderRepeatDays: type: integer + nullable: true + x-omitempty: false skipMultilineEncoding: type: boolean createdAt: @@ -341,16 +384,20 @@ components: type: boolean rotationId: type: string + nullable: true + x-omitempty: false secretPath: type: string secretValueHidden: type: boolean secretMetadata: type: array + x-omitempty: false items: $ref: '#/components/schemas/ResourceMetadata' tags: type: array + x-omitempty: false items: $ref: '#/components/schemas/SecretTag' @@ -394,8 +441,12 @@ components: type: string secretReminderNote: type: string + nullable: true + x-omitempty: false secretReminderRepeatDays: type: integer + nullable: true + x-omitempty: false skipMultilineEncoding: type: boolean actor: @@ -404,10 +455,13 @@ components: type: boolean rotationId: type: string + nullable: true + x-omitempty: false secretValueHidden: type: boolean secretMetadata: type: array + x-omitempty: false items: $ref: '#/components/schemas/ResourceMetadata' diff --git a/backend-go/internal/server/api/secrets_routes.go b/backend-go/internal/server/api/secrets_routes.go new file mode 100644 index 00000000000..3e33c9bcd73 --- /dev/null +++ b/backend-go/internal/server/api/secrets_routes.go @@ -0,0 +1,31 @@ +package api + +import ( + "log/slog" + + "github.com/infisical/api/internal/ee/services/ratelimit" + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/internal/services/auth/apiauth" +) + +func (r *Router) registerSecretsRoutes() { + l := r.logger.With(slog.String("product", "secrets")) + + secretsHandler := secret.NewHandler(&secret.Deps{ + Logger: l, + Permission: r.services.Permission, + Project: r.services.Platform().Project, + AuditLog: r.services.AuditLog, + Secrets: r.services.Secrets().Secret, + KMS: r.services.KMS, + SecretCache: r.services.Secrets().SecretCache, + }) + + secret.RegisterRoutes(r.Router, secretsHandler, + secret.WithMiddleware(r.auth.RequireAuth( + apiauth.WithAuthModes(apiauth.JWTAuth, apiauth.IdentityAccessTokenAuth, apiauth.ServiceTokenAuth), + )), + secret.WithMiddleware(r.services.RateLimit.Middleware(ratelimit.PresetSecrets)), + secret.WithErrorHandler(NewErrorHandler(l)), + ) +} diff --git a/backend-go/internal/server/api/templates/chi.tmpl b/backend-go/internal/server/api/templates/chi.tmpl new file mode 100644 index 00000000000..352e753b5b9 --- /dev/null +++ b/backend-go/internal/server/api/templates/chi.tmpl @@ -0,0 +1,80 @@ +{{/* Chi-specific template blocks - extended with RegisterRoutes */}} + +{{define "router-import"}}"github.com/go-chi/chi/v5"{{end}} + +{{define "get-path-param"}}chi.URLParam(r, "{{ . }}"){{end}} + +{{/* router-path converts OpenAPI path to chi format: /** -> /* */}} +{{define "router-path"}}{{ escapeGoString (replace . "/**" "/*") }}{{end}} + +{{define "router-config"}} +type routerConfig struct { + middlewares []func(http.Handler) http.Handler + errHandler OapiErrorHandler + jsonBodyDecoder runtime.JSONBodyDecoderFunc +} + +// WithMiddleware adds middleware to the router. +func WithMiddleware(mw func(http.Handler) http.Handler) RouterOption { + return func(cfg *routerConfig) { + cfg.middlewares = append(cfg.middlewares, mw) + } +} + +// WithErrorHandler sets a custom error handler for the router. +// If not set, OapiDefaultErrorHandler is used. +func WithErrorHandler(h OapiErrorHandler) RouterOption { + return func(cfg *routerConfig) { + cfg.errHandler = h + } +} + +// WithJSONBodyDecoder sets a custom function for decoding JSON request bodies. +// If not set, runtime.DecodeJSONBody is used. +func WithJSONBodyDecoder(fn runtime.JSONBodyDecoderFunc) RouterOption { + return func(cfg *routerConfig) { + cfg.jsonBodyDecoder = fn + } +} +{{end}} + +{{define "new-router"}} +{{- $config := .Config -}} +{{- $operations := .Operations -}} +{{- $serviceName := $config.Generate.Handler.Name -}} +// NewRouter creates a new chi.Router with the given service implementation. +func NewRouter(svc {{ $serviceName }}Interface, opts ...RouterOption) chi.Router { + r := chi.NewRouter() + RegisterRoutes(r, svc, opts...) + return r +} + +// RegisterRoutes registers all routes on an existing chi.Router. +// Middlewares are applied within a Group so they only affect these routes. +func RegisterRoutes(r chi.Router, svc {{ $serviceName }}Interface, opts ...RouterOption) { + cfg := &routerConfig{} + for _, opt := range opts { + opt(cfg) + } + + {{- if $operations }} + + adapter := NewHTTPAdapter(svc, cfg.errHandler) + if cfg.jsonBodyDecoder != nil { + adapter.jsonBodyDecoder = cfg.jsonBodyDecoder + } + + r.Group(func(r chi.Router) { + for _, mw := range cfg.middlewares { + r.Use(mw) + } + {{- range $operations }}{{ $op := . }} + r.Method("{{ $op.Method }}", "{{template "router-path" $op.Path}}", http.HandlerFunc(adapter.{{ $op.ID | ucFirst }})) + {{- end }} + }) + {{- else }} + _ = svc // unused when no operations + _ = cfg + {{- end }} +} +{{end}} diff --git a/backend-go/internal/server/middlewares/etag.go b/backend-go/internal/server/middlewares/etag.go new file mode 100644 index 00000000000..2b73eee6ce6 --- /dev/null +++ b/backend-go/internal/server/middlewares/etag.go @@ -0,0 +1,118 @@ +package middlewares + +import ( + "bytes" + "crypto/sha1" + "encoding/base64" + "net/http" + "strings" +) + +// ETag returns middleware that automatically generates ETags for responses +// and returns 304 Not Modified when If-None-Match matches. +// Equivalent to @fastify/etag in the Node.js backend. +// +// The middleware: +// - Computes SHA-1 hash of response body for GET/HEAD requests with 2xx status +// - Sets ETag header as quoted base64-encoded hash (e.g., "abc123...") +// - Returns 304 Not Modified if If-None-Match header matches the ETag +// - Skips ETag generation if one is already set by the handler +// - Only processes string/buffer responses (JSON, text, etc.) +func ETag(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + next.ServeHTTP(w, r) + return + } + + ifNoneMatch := r.Header.Get("If-None-Match") + + rw := &etagResponseWriter{ + ResponseWriter: w, + buf: &bytes.Buffer{}, + ifNoneMatch: ifNoneMatch, + } + + next.ServeHTTP(rw, r) + + rw.finalize() + }) +} + +type etagResponseWriter struct { + http.ResponseWriter + buf *bytes.Buffer + ifNoneMatch string + statusCode int + wroteHeader bool + finalized bool +} + +func (rw *etagResponseWriter) WriteHeader(statusCode int) { + rw.statusCode = statusCode + rw.wroteHeader = true +} + +func (rw *etagResponseWriter) Write(b []byte) (int, error) { + if !rw.wroteHeader { + rw.statusCode = http.StatusOK + rw.wroteHeader = true + } + return rw.buf.Write(b) +} + +func (rw *etagResponseWriter) finalize() { + if rw.finalized { + return + } + rw.finalized = true + + body := rw.buf.Bytes() + + existingEtag := rw.Header().Get("ETag") + etag := existingEtag + + if existingEtag == "" && len(body) > 0 && rw.statusCode >= 200 && rw.statusCode < 300 { + etag = generateETag(body) + rw.Header().Set("ETag", etag) + } + + if etag != "" && rw.ifNoneMatch != "" && etagMatches(etag, rw.ifNoneMatch) { + rw.Header().Del("Content-Length") + rw.Header().Del("Content-Type") + rw.ResponseWriter.WriteHeader(http.StatusNotModified) + return + } + + if rw.statusCode != 0 { + rw.ResponseWriter.WriteHeader(rw.statusCode) + } + if len(body) > 0 { + _, _ = rw.ResponseWriter.Write(body) + } +} + +func generateETag(payload []byte) string { + hash := sha1.Sum(payload) + return `"` + base64.StdEncoding.EncodeToString(hash[:]) + `"` +} + +func etagMatches(etag, ifNoneMatch string) bool { + etag = strings.TrimSpace(etag) + ifNoneMatch = strings.TrimSpace(ifNoneMatch) + + if etag == ifNoneMatch { + return true + } + + weakEtag := "W/" + etag + if weakEtag == ifNoneMatch { + return true + } + + if strings.HasPrefix(ifNoneMatch, "W/") && ifNoneMatch[2:] == etag { + return true + } + + return false +} diff --git a/backend-go/internal/server/middlewares/etag_test.go b/backend-go/internal/server/middlewares/etag_test.go new file mode 100644 index 00000000000..baa6ca2bda2 --- /dev/null +++ b/backend-go/internal/server/middlewares/etag_test.go @@ -0,0 +1,210 @@ +package middlewares + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestETag_GeneratesETagForSuccessfulGET(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message":"hello"}`)) + }) + + wrapped := ETag(handler) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + etag := rec.Header().Get("ETag") + assert.NotEmpty(t, etag) + assert.Equal(t, `"`, string(etag[0])) + assert.Equal(t, `"`, string(etag[len(etag)-1])) + assert.Equal(t, `{"message":"hello"}`, rec.Body.String()) +} + +func TestETag_Returns304WhenIfNoneMatchMatches(t *testing.T) { + responseBody := `{"data":"test"}` + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(responseBody)) + }) + + wrapped := ETag(handler) + + req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody) + rec1 := httptest.NewRecorder() + wrapped.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + etag := rec1.Header().Get("ETag") + require.NotEmpty(t, etag) + + req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody) + req2.Header.Set("If-None-Match", etag) + rec2 := httptest.NewRecorder() + wrapped.ServeHTTP(rec2, req2) + + assert.Equal(t, http.StatusNotModified, rec2.Code) + assert.Empty(t, rec2.Body.String()) + assert.Equal(t, etag, rec2.Header().Get("ETag")) +} + +func TestETag_Returns200WhenIfNoneMatchDiffers(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":"test"}`)) + }) + + wrapped := ETag(handler) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody) + req.Header.Set("If-None-Match", `"invalid-etag"`) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.NotEmpty(t, rec.Body.String()) +} + +func TestETag_SkipsNonGETMethods(t *testing.T) { + methods := []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"created":true}`)) + }) + + wrapped := ETag(handler) + req := httptest.NewRequestWithContext(t.Context(), method, "/test", http.NoBody) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, rec.Header().Get("ETag")) + }) + } +} + +func TestETag_PreservesHandlerSetETag(t *testing.T) { + customEtag := `"custom-etag-123"` + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", customEtag) + _, _ = w.Write([]byte(`{"data":"test"}`)) + }) + + wrapped := ETag(handler) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, customEtag, rec.Header().Get("ETag")) +} + +func TestETag_SkipsNon2xxResponses(t *testing.T) { + tests := []struct { + name string + status int + }{ + {"bad request", http.StatusBadRequest}, + {"not found", http.StatusNotFound}, + {"internal error", http.StatusInternalServerError}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"error":"something went wrong"}`)) + }) + + wrapped := ETag(handler) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody) + rec := httptest.NewRecorder() + + wrapped.ServeHTTP(rec, req) + + assert.Equal(t, tc.status, rec.Code) + assert.Empty(t, rec.Header().Get("ETag")) + }) + } +} + +func TestETag_ConsistentHashForSameContent(t *testing.T) { + content := `{"data":"consistent"}` + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(content)) + }) + + wrapped := ETag(handler) + + req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/path1", http.NoBody) + rec1 := httptest.NewRecorder() + wrapped.ServeHTTP(rec1, req1) + + req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/path2", http.NoBody) + rec2 := httptest.NewRecorder() + wrapped.ServeHTTP(rec2, req2) + + assert.Equal(t, rec1.Header().Get("ETag"), rec2.Header().Get("ETag")) +} + +func TestEtagMatches(t *testing.T) { + tests := []struct { + name string + etag string + ifNoneMatch string + expected bool + }{ + { + name: "exact match", + etag: `"abc123"`, + ifNoneMatch: `"abc123"`, + expected: true, + }, + { + name: "no match", + etag: `"abc123"`, + ifNoneMatch: `"xyz789"`, + expected: false, + }, + { + name: "weak prefix in request", + etag: `"abc123"`, + ifNoneMatch: `W/"abc123"`, + expected: true, + }, + { + name: "weak prefix in both", + etag: `W/"abc123"`, + ifNoneMatch: `W/"abc123"`, + expected: true, + }, + { + name: "spaces trimmed", + etag: `"abc123"`, + ifNoneMatch: ` "abc123" `, + expected: true, + }, + { + name: "empty if-none-match returns false", + etag: `"abc123"`, + ifNoneMatch: "", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := etagMatches(tc.etag, tc.ifNoneMatch) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/backend-go/internal/server/server.go b/backend-go/internal/server/server.go index abd2cc6db24..82b77a19e82 100644 --- a/backend-go/internal/server/server.go +++ b/backend-go/internal/server/server.go @@ -8,33 +8,29 @@ import ( "sync" "time" - "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/infisical/api/internal/config" "github.com/infisical/api/internal/libs/requestid" "github.com/infisical/api/internal/server/api" "github.com/infisical/api/internal/server/middlewares" + "github.com/infisical/api/internal/services" ) // Server is the HTTP server for the Infisical API. type Server struct { - services *api.Services + services *services.Services config *config.Config logger *slog.Logger - router chi.Router + router *api.Router } // NewServer creates a new HTTP server with chi routing. -func NewServer(services *api.Services, cfg *config.Config, logger *slog.Logger) *Server { - router := chi.NewRouter() - - // Register domain routes - api.RegisterPlatformRoutes(router, logger, services.Platform) - api.RegisterSecretManagerRoutes(router, logger, services.Platform, services.SecretManager) +func NewServer(svc *services.Services, cfg *config.Config, logger *slog.Logger) *Server { + router := api.NewRouter(logger, svc) return &Server{ - services: services, + services: svc, config: cfg, logger: logger, router: router, @@ -47,6 +43,7 @@ func (s *Server) Listen(ctx context.Context, addr string, wg *sync.WaitGroup, er // Middleware stack (applied in reverse order - last wraps first) // Inner middlewares (closest to handler) first, outer middlewares last + handler = middlewares.ETag(handler) handler = requestLogger(handler, s.logger) handler = chimw.StripSlashes(handler) handler = middlewares.Timeout(100 * time.Second)(handler) // Match Node.js connectionTimeout diff --git a/backend-go/internal/services/permission/permission.go b/backend-go/internal/services/permission/permission.go index 71d122aa4d4..755aed615d2 100644 --- a/backend-go/internal/services/permission/permission.go +++ b/backend-go/internal/services/permission/permission.go @@ -15,6 +15,7 @@ import ( "github.com/infisical/api/internal/database/pg" "github.com/infisical/api/internal/database/pg/qb" "github.com/infisical/api/internal/database/pg/sqln" + "github.com/infisical/api/internal/libs/cache" "github.com/infisical/api/internal/libs/errutil" "github.com/infisical/api/internal/libs/fn" "github.com/infisical/api/internal/services/auth" @@ -65,6 +66,16 @@ type GetProjectPermissionResult struct { Permission ProjectPermission Memberships []Membership HasProjectEnforcement func(check string) bool + rulesJSON string // interpolated rules JSON, kept private for hashing +} + +// PermissionRulesHash returns the SHA256 hash of the interpolated permission rules JSON. +// Used for cache key generation to ensure cache isolation per unique permission set. +func (r *GetProjectPermissionResult) PermissionRulesHash() string { + if r.rulesJSON == "" { + return "" + } + return cache.HashString(r.rulesJSON) } // HasRole reports whether any membership includes the given role slug. @@ -254,6 +265,7 @@ func (p *Service) GetProjectPermission(ctx context.Context, args *GetProjectPerm Permission: ProjectPermission{Ability: ability}, Memberships: memberships, HasProjectEnforcement: checkProjectEnforcement(projectDetails), + rulesJSON: rulesStr, }, nil } @@ -327,6 +339,7 @@ func (p *Service) getServiceTokenProjectPermission( Permission: ProjectPermission{Ability: ability}, Memberships: nil, HasProjectEnforcement: checkProjectEnforcement(serviceTokenProject), + rulesJSON: string(rulesJSON), }, nil } @@ -789,6 +802,167 @@ func (s *Service) findIdentityName(ctx context.Context, identityID uuid.UUID) (s return name, nil } +// GetPermissionFingerprintArgs holds the input for the permission fingerprint query. +type GetPermissionFingerprintArgs struct { + ProjectID string + OrgID uuid.UUID + ActorID uuid.UUID + ActorType auth.ActorType +} + +// GetPermissionFingerprint returns a lightweight hash of membership DB rows for ETag validation. +// This is faster than computing the full permission rules hash since it only reads IDs + timestamps. +// Port of permission-dal.ts:947-1050. +func (s *Service) GetPermissionFingerprint(ctx context.Context, args *GetPermissionFingerprintArgs) (string, error) { + // Build group subquery for group membership + var groupSubquery string + if args.ActorType == auth.ActorTypeUser { + groupSubquery = ` + SELECT grp.id FROM groups grp + INNER JOIN user_group_membership ugm ON ugm."groupId" = grp.id + WHERE ugm."userId" = @actorID + ` + } else { + groupSubquery = ` + SELECT grp.id FROM groups grp + INNER JOIN identity_group_membership igm ON igm."groupId" = grp.id + WHERE igm."identityId" = @actorID + ` + } + + // Build actor condition + var actorCondition string + if args.ActorType == auth.ActorTypeUser { + actorCondition = `(membership."actorUserId" = @actorID OR membership."actorGroupId" IN (` + groupSubquery + `))` + } else { + actorCondition = `(membership."actorIdentityId" = @actorID OR membership."actorGroupId" IN (` + groupSubquery + `))` + } + + // Build additional privilege join condition + var apActorCondition string + if args.ActorType == auth.ActorTypeIdentity { + apActorCondition = `membership."actorIdentityId" = addlPriv."actorIdentityId"` + } else { + apActorCondition = `membership."actorUserId" = addlPriv."actorUserId"` + } + + // Build metadata join condition + var metaJoinCondition string + if args.ActorType == auth.ActorTypeUser { + metaJoinCondition = `identityMeta."userId" = @actorID AND membership."scopeOrgId" = identityMeta."orgId"` + } else { + metaJoinCondition = `identityMeta."identityId" = @actorID` + } + + query := ` + SELECT + membership.id AS "mId", + membership."isActive" AS "mActive", + membership.status AS "mStatus", + project."deleteAfter" AS "pDel", + memberRole.id AS "rId", + memberRole."updatedAt" AS "rUp", + customRole."updatedAt" AS "crUp", + addlPriv.id AS "pId", + addlPriv."updatedAt" AS "pUp", + identityMeta.id AS "imId", + identityMeta."updatedAt" AS "imUp", + CASE WHEN memberRole."isTemporary" AND NOW() >= memberRole."temporaryAccessEndTime" THEN true ELSE false END AS "rExp", + CASE WHEN addlPriv."isTemporary" AND NOW() >= addlPriv."temporaryAccessEndTime" THEN true ELSE false END AS "pExp" + FROM memberships membership + INNER JOIN membership_roles memberRole ON membership.id = memberRole."membershipId" + LEFT JOIN roles customRole ON memberRole."customRoleId" = customRole.id + LEFT JOIN additional_privileges addlPriv ON ` + apActorCondition + ` AND membership."scopeProjectId" = addlPriv."projectId" + LEFT JOIN identity_metadata identityMeta ON ` + metaJoinCondition + ` + LEFT JOIN projects project ON membership."scopeProjectId" = project.id + WHERE membership."scopeOrgId" = @orgID + AND ( + (membership.scope = 'project' AND membership."scopeProjectId" = @projectID) + OR (membership.scope = 'resource' AND membership."scopeProjectId" = @projectID) + OR membership.scope = 'organization' + ) + AND ` + actorCondition + ` + ORDER BY membership.id, memberRole.id, addlPriv.id, identityMeta.id + ` + + args2 := pgx.NamedArgs{ + "orgID": args.OrgID, + "actorID": args.ActorID, + "projectID": args.ProjectID, + } + + rows, err := s.db.Replica().Query(ctx, query, args2) + if err != nil { + return "", err + } + defer rows.Close() + + // Collect rows as JSON-serializable data for hashing + var rowData []map[string]any + for rows.Next() { + var ( + mID uuid.UUID + mActive sql.Null[bool] + mStatus sql.Null[string] + pDel sql.Null[time.Time] + rID sql.Null[uuid.UUID] + rUp sql.Null[time.Time] + crUp sql.Null[time.Time] + pID sql.Null[uuid.UUID] + pUp sql.Null[time.Time] + imID sql.Null[uuid.UUID] + imUp sql.Null[time.Time] + rExp bool + pExp bool + ) + + if err := rows.Scan(&mID, &mActive, &mStatus, &pDel, &rID, &rUp, &crUp, &pID, &pUp, &imID, &imUp, &rExp, &pExp); err != nil { + return "", err + } + + row := map[string]any{ + "mId": mID.String(), + "mActive": mActive.V, + "mStatus": mStatus.V, + "rExp": rExp, + "pExp": pExp, + } + + if pDel.Valid { + row["pDel"] = pDel.V.Format(time.RFC3339) + } + if rID.Valid { + row["rId"] = rID.V.String() + } + if rUp.Valid { + row["rUp"] = rUp.V.Format(time.RFC3339) + } + if crUp.Valid { + row["crUp"] = crUp.V.Format(time.RFC3339) + } + if pID.Valid { + row["pId"] = pID.V.String() + } + if pUp.Valid { + row["pUp"] = pUp.V.Format(time.RFC3339) + } + if imID.Valid { + row["imId"] = imID.V.String() + } + if imUp.Valid { + row["imUp"] = imUp.V.Format(time.RFC3339) + } + + rowData = append(rowData, row) + } + + if err := rows.Err(); err != nil { + return "", err + } + + return cache.GenerateHash(rowData), nil +} + // findServiceTokenByID returns service token details needed for permission checks. func (s *Service) findServiceTokenByID(ctx context.Context, tokenID string) (*serviceTokenDetail, error) { query := ` diff --git a/backend-go/internal/services/platform.go b/backend-go/internal/services/platform.go new file mode 100644 index 00000000000..38717bcb495 --- /dev/null +++ b/backend-go/internal/services/platform.go @@ -0,0 +1,32 @@ +package services + +import ( + "context" + + "github.com/infisical/api/internal/services/auditlog" + "github.com/infisical/api/internal/services/project" +) + +// PlatformGroup holds platform-level services. +type PlatformGroup struct { + Project *project.Service + AuditLogQueueHandler *auditlog.QueueHandler +} + +func (s *Services) initPlatform(ctx context.Context) { + projectSvc := project.NewService(ctx, s.infra.Logger, &project.Deps{DB: s.infra.DB}) + + auditLogQueueHandler := auditlog.NewQueueHandler(ctx, s.infra.Logger, &auditlog.QueueHandlerDeps{ + DB: s.infra.DB, + Project: projectSvc, + License: s.infra.License, + Config: s.infra.Config, + KeyStore: s.infra.KeyStore, + }) + auditLogQueueHandler.Register(s.infra.Queue) + + s.platform = &PlatformGroup{ + Project: projectSvc, + AuditLogQueueHandler: auditLogQueueHandler, + } +} diff --git a/backend-go/internal/services/secrets.go b/backend-go/internal/services/secrets.go new file mode 100644 index 00000000000..226edf8cc35 --- /dev/null +++ b/backend-go/internal/services/secrets.go @@ -0,0 +1,42 @@ +package services + +import ( + "context" + + "github.com/infisical/api/internal/services/secrets/environment" + "github.com/infisical/api/internal/services/secrets/secret" + "github.com/infisical/api/internal/services/secrets/secretcache" + "github.com/infisical/api/internal/services/secrets/secretfolder" + "github.com/infisical/api/internal/services/secrets/secretimport" +) + +// SecretsGroup holds secrets-related services. +type SecretsGroup struct { + Secret *secret.Service + SecretFolder *secretfolder.Service + SecretImport *secretimport.Service + SecretCache *secretcache.Service + Environment *environment.Service +} + +func (s *Services) initSecrets(ctx context.Context) { + secretFolderSvc := secretfolder.NewService(ctx, s.infra.Logger, &secretfolder.Deps{DB: s.infra.DB}) + secretImportSvc := secretimport.NewService(ctx, s.infra.Logger, &secretimport.Deps{DB: s.infra.DB}) + environmentSvc := environment.NewService(ctx, s.infra.Logger, &environment.Deps{DB: s.infra.DB}) + secretCacheSvc := secretcache.NewService(ctx, s.infra.Logger, &secretcache.Deps{KeyStore: s.infra.KeyStore}) + + secretSvc := secret.NewService(ctx, s.infra.Logger, &secret.Deps{ + DB: s.infra.DB, + SecretFolderService: secretFolderSvc, + SecretImportService: secretImportSvc, + KMSService: s.KMS, + }) + + s.secrets = &SecretsGroup{ + Secret: secretSvc, + SecretFolder: secretFolderSvc, + SecretImport: secretImportSvc, + SecretCache: secretCacheSvc, + Environment: environmentSvc, + } +} diff --git a/backend-go/internal/services/secretmanager/environment/environment.go b/backend-go/internal/services/secrets/environment/environment.go similarity index 100% rename from backend-go/internal/services/secretmanager/environment/environment.go rename to backend-go/internal/services/secrets/environment/environment.go diff --git a/backend-go/internal/services/secretmanager/secret/expansion.go b/backend-go/internal/services/secrets/secret/expansion.go similarity index 100% rename from backend-go/internal/services/secretmanager/secret/expansion.go rename to backend-go/internal/services/secrets/secret/expansion.go diff --git a/backend-go/internal/services/secretmanager/secret/expansion_test.go b/backend-go/internal/services/secrets/secret/expansion_test.go similarity index 100% rename from backend-go/internal/services/secretmanager/secret/expansion_test.go rename to backend-go/internal/services/secrets/secret/expansion_test.go diff --git a/backend-go/internal/services/secretmanager/secret/get_secret_by_name.go b/backend-go/internal/services/secrets/secret/get_secret_by_name.go similarity index 85% rename from backend-go/internal/services/secretmanager/secret/get_secret_by_name.go rename to backend-go/internal/services/secrets/secret/get_secret_by_name.go index b58b7de7bff..3ecdd00615f 100644 --- a/backend-go/internal/services/secretmanager/secret/get_secret_by_name.go +++ b/backend-go/internal/services/secrets/secret/get_secret_by_name.go @@ -8,8 +8,8 @@ import ( "github.com/infisical/api/internal/libs/errutil" "github.com/infisical/api/internal/services/kms" - "github.com/infisical/api/internal/services/secretmanager/secretfolder" - "github.com/infisical/api/internal/services/secretmanager/secretimport" + "github.com/infisical/api/internal/services/secrets/secretfolder" + "github.com/infisical/api/internal/services/secrets/secretimport" ) // GetSecretByNameOpts contains options for getting a single secret. @@ -22,6 +22,7 @@ type GetSecretByNameOpts struct { SecretType string UserID *uuid.UUID IncludeImports bool + Version *int // when set, read this historical version instead of the live secret } // GetSecretByNameResult contains the result of getting a secret by name. @@ -61,7 +62,17 @@ func (s *Service) GetSecretByName(ctx context.Context, opts *GetSecretByNameOpts secretType = "shared" } - foundSecret, err := s.FindByKey(ctx, folderNode.ID, opts.SecretName, secretType, opts.UserID) + var keyOpts []FindByKeyOption + if secretType == "personal" && opts.UserID != nil { + keyOpts = append(keyOpts, WithPersonalType(*opts.UserID)) + } + + lookupOpts := keyOpts + if opts.Version != nil { + lookupOpts = append(append([]FindByKeyOption{}, keyOpts...), WithVersion(*opts.Version)) + } + + foundSecret, err := s.FindByKey(ctx, folderNode.ID, opts.SecretName, lookupOpts...) if err != nil { return nil, errutil.DatabaseErr("Failed to find secret").WithErrf("GetSecretByName: %w", err) } @@ -71,7 +82,9 @@ func (s *Service) GetSecretByName(ctx context.Context, opts *GetSecretByNameOpts var allImports []secretimport.ResolvedImport - if foundSecret == nil && opts.IncludeImports { + // Imports apply to live lookups only; a versioned read targets a specific + // secret's history and never falls back to imports. + if foundSecret == nil && opts.IncludeImports && opts.Version == nil { importLookup, err := s.secretImportService.LoadProjectImports(ctx, opts.ProjectID) if err != nil { return nil, errutil.DatabaseErr("Failed to load imports").WithErrf("GetSecretByName: %w", err) @@ -90,7 +103,7 @@ func (s *Service) GetSecretByName(ctx context.Context, opts *GetSecretByNameOpts // Search imports in reverse order (later imports have higher priority) for i := len(allImports) - 1; i >= 0; i-- { imp := &allImports[i] - importedSecret, err := s.FindByKey(ctx, imp.FolderID, opts.SecretName, secretType, opts.UserID) + importedSecret, err := s.FindByKey(ctx, imp.FolderID, opts.SecretName, keyOpts...) if err != nil { continue } diff --git a/backend-go/internal/services/secretmanager/secret/list_secrets.go b/backend-go/internal/services/secrets/secret/list_secrets.go similarity index 98% rename from backend-go/internal/services/secretmanager/secret/list_secrets.go rename to backend-go/internal/services/secrets/secret/list_secrets.go index 7b4b2ee5ad1..aa14a4d3f15 100644 --- a/backend-go/internal/services/secretmanager/secret/list_secrets.go +++ b/backend-go/internal/services/secrets/secret/list_secrets.go @@ -8,8 +8,8 @@ import ( "github.com/infisical/api/internal/libs/errutil" "github.com/infisical/api/internal/services/kms" - "github.com/infisical/api/internal/services/secretmanager/secretfolder" - "github.com/infisical/api/internal/services/secretmanager/secretimport" + "github.com/infisical/api/internal/services/secrets/secretfolder" + "github.com/infisical/api/internal/services/secrets/secretimport" ) // ListSecretsOpts contains options for listing secrets. diff --git a/backend-go/internal/services/secretmanager/secret/secret.go b/backend-go/internal/services/secrets/secret/secret.go similarity index 77% rename from backend-go/internal/services/secretmanager/secret/secret.go rename to backend-go/internal/services/secrets/secret/secret.go index f4c42719fc4..d6acfb23bc5 100644 --- a/backend-go/internal/services/secretmanager/secret/secret.go +++ b/backend-go/internal/services/secrets/secret/secret.go @@ -15,8 +15,8 @@ import ( "github.com/infisical/api/internal/database/pg/sqln" "github.com/infisical/api/internal/libs/fn" "github.com/infisical/api/internal/services/kms" - "github.com/infisical/api/internal/services/secretmanager/secretfolder" - "github.com/infisical/api/internal/services/secretmanager/secretimport" + "github.com/infisical/api/internal/services/secrets/secretfolder" + "github.com/infisical/api/internal/services/secrets/secretimport" ) const secretValueHiddenMask = "" @@ -337,49 +337,108 @@ func (s *Service) FindByFolderIds( return sqln.GroupRows(flatSecrets, secretGrouper), nil } -func (s *Service) FindByKey( - ctx context.Context, - folderID uuid.UUID, - key string, - secretType string, - userID *uuid.UUID, -) (*Secret, error) { - where := qb.NewWhere(). - Add(`secret."folderId" = @folderID`). - Add("secret.key = @key"). - Add("secret.type = @secretType") - - if secretType == "personal" && userID != nil { - where.Add(`secret."userId" = @userID`) - } else { - where.Add(`secret."userId" IS NULL`) +// findByKeyConfig holds the optional behavior for FindByKey. +type findByKeyConfig struct { + secretType string // "shared" (default) or "personal" + userID *uuid.UUID + version *int // when set, the secret is read from secret_versions_v2 +} + +// FindByKeyOption configures an optional FindByKey lookup behavior. +type FindByKeyOption func(*findByKeyConfig) + +// WithPersonalType scopes the lookup to the given user's personal secret instead +// of the shared secret. +func WithPersonalType(userID uuid.UUID) FindByKeyOption { + return func(c *findByKeyConfig) { + c.secretType = "personal" + c.userID = &userID } +} - query := ` - SELECT - secret.id, secret.version, secret.type, secret.key, secret."encryptedValue", secret."encryptedComment", - secret."skipMultilineEncoding", secret.metadata, secret."userId", secret."folderId", secret."createdAt", secret."updatedAt", - tag.id AS tag_id, tag.slug AS tag_slug, tag.color AS tag_color, - meta.id AS meta_id, meta.key AS meta_key, meta.value AS meta_value, meta."encryptedValue" AS meta_encrypted_value, - rotationMapping."rotationId", - reminder.message AS reminder_note, reminder."repeatDays" AS reminder_repeat_days, - recipient.id AS recipient_id, recipientUser.id AS recipient_user_id, recipientUser.username AS recipient_username, recipientUser.email AS recipient_email - FROM secrets_v2 secret - LEFT JOIN secret_v2_tag_junction tagJunction ON secret.id = tagJunction."secrets_v2Id" - LEFT JOIN secret_tags tag ON tagJunction."secret_tagsId" = tag.id - LEFT JOIN resource_metadata meta ON secret.id = meta."secretId" - LEFT JOIN secret_rotation_v2_secret_mappings rotationMapping ON secret.id = rotationMapping."secretId" - LEFT JOIN reminders reminder ON secret.id = reminder."secretId" - LEFT JOIN reminders_recipients recipient ON reminder.id = recipient."reminderId" - LEFT JOIN users recipientUser ON recipient."userId" = recipientUser.id - WHERE ` + where.String() + ` - ORDER BY meta."createdAt" ASC NULLS FIRST, meta.id ASC NULLS FIRST, tag."createdAt" ASC NULLS FIRST, tag.id ASC NULLS FIRST` +// WithVersion reads a historical version from secret_versions_v2 instead of the +// live secret. Returns nil when that version does not exist. +func WithVersion(version int) FindByKeyOption { + return func(c *findByKeyConfig) { + c.version = &version + } +} + +// FindByKey returns a secret by folder + key, or nil if not found. By default it +// reads the live shared secret; use WithPersonalType / WithVersion to scope the +// lookup. The version branch (secret_versions_v2) selects the same column shape +func (s *Service) FindByKey(ctx context.Context, folderID uuid.UUID, key string, opts ...FindByKeyOption) (*Secret, error) { + cfg := findByKeyConfig{secretType: "shared"} + for _, opt := range opts { + opt(&cfg) + } args := pgx.NamedArgs{ "folderID": folderID, "key": key, - "secretType": secretType, - "userID": userID, + "secretType": cfg.secretType, + "userID": cfg.userID, + } + + var query string + if cfg.version != nil { + args["version"] = *cfg.version + + where := qb.NewWhere(). + Add(`sv."folderId" = @folderID`). + Add("sv.key = @key"). + Add("sv.type = @secretType"). + Add("sv.version = @version") + if cfg.secretType == "personal" && cfg.userID != nil { + where.Add(`sv."userId" = @userID`) + } else { + where.Add(`sv."userId" IS NULL`) + } + + query = ` + SELECT + sv."secretId" AS id, sv.version, sv.type, sv.key, sv."encryptedValue", sv."encryptedComment", + sv."skipMultilineEncoding", sv.metadata, sv."userId", sv."folderId", sv."createdAt", sv."updatedAt", + tag.id AS tag_id, tag.slug AS tag_slug, tag.color AS tag_color, + NULL::uuid AS meta_id, NULL::text AS meta_key, NULL::text AS meta_value, NULL::bytea AS meta_encrypted_value, + NULL::uuid AS "rotationId", + sv."reminderNote" AS reminder_note, sv."reminderRepeatDays" AS reminder_repeat_days, + NULL::uuid AS recipient_id, NULL::uuid AS recipient_user_id, NULL::text AS recipient_username, NULL::text AS recipient_email + FROM secret_versions_v2 sv + LEFT JOIN secret_version_v2_tag_junction tagJunction ON sv.id = tagJunction."secret_versions_v2Id" + LEFT JOIN secret_tags tag ON tagJunction."secret_tagsId" = tag.id + WHERE ` + where.String() + ` + ORDER BY tag."createdAt" ASC NULLS FIRST, tag.id ASC NULLS FIRST` + } else { + where := qb.NewWhere(). + Add(`secret."folderId" = @folderID`). + Add("secret.key = @key"). + Add("secret.type = @secretType") + if cfg.secretType == "personal" && cfg.userID != nil { + where.Add(`secret."userId" = @userID`) + } else { + where.Add(`secret."userId" IS NULL`) + } + + query = ` + SELECT + secret.id, secret.version, secret.type, secret.key, secret."encryptedValue", secret."encryptedComment", + secret."skipMultilineEncoding", secret.metadata, secret."userId", secret."folderId", secret."createdAt", secret."updatedAt", + tag.id AS tag_id, tag.slug AS tag_slug, tag.color AS tag_color, + meta.id AS meta_id, meta.key AS meta_key, meta.value AS meta_value, meta."encryptedValue" AS meta_encrypted_value, + rotationMapping."rotationId", + reminder.message AS reminder_note, reminder."repeatDays" AS reminder_repeat_days, + recipient.id AS recipient_id, recipientUser.id AS recipient_user_id, recipientUser.username AS recipient_username, recipientUser.email AS recipient_email + FROM secrets_v2 secret + LEFT JOIN secret_v2_tag_junction tagJunction ON secret.id = tagJunction."secrets_v2Id" + LEFT JOIN secret_tags tag ON tagJunction."secret_tagsId" = tag.id + LEFT JOIN resource_metadata meta ON secret.id = meta."secretId" + LEFT JOIN secret_rotation_v2_secret_mappings rotationMapping ON secret.id = rotationMapping."secretId" + LEFT JOIN reminders reminder ON secret.id = reminder."secretId" + LEFT JOIN reminders_recipients recipient ON reminder.id = recipient."reminderId" + LEFT JOIN users recipientUser ON recipient."userId" = recipientUser.id + WHERE ` + where.String() + ` + ORDER BY meta."createdAt" ASC NULLS FIRST, meta.id ASC NULLS FIRST, tag."createdAt" ASC NULLS FIRST, tag.id ASC NULLS FIRST` } rows, err := s.db.Replica().Query(ctx, query, args) diff --git a/backend-go/internal/services/secrets/secretcache/secretcache.go b/backend-go/internal/services/secrets/secretcache/secretcache.go new file mode 100644 index 00000000000..61ae01b66fe --- /dev/null +++ b/backend-go/internal/services/secrets/secretcache/secretcache.go @@ -0,0 +1,231 @@ +package secretcache + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "time" + + "github.com/google/uuid" + + "github.com/infisical/api/internal/libs/cache" + "github.com/infisical/api/internal/libs/jitter" + "github.com/infisical/api/internal/services/kms" +) + +const ( + listSecretsCachePrefix = "secret-manager" + listSecretsTable = "secrets_v2" + listSecretsCacheTTL = 10 * time.Minute + listSecretsCacheJitter = 2 * time.Minute + listSecretsEtagTTL = 15 * time.Minute + listSecretsMaxCacheBytes = 25 * 1024 * 1024 // 25MB +) + +// KeyStore provides the subset of keystore operations needed for caching. +type KeyStore interface { + GetItem(ctx context.Context, key string) (string, error) + SetItemWithExpiry(ctx context.Context, key string, expiry time.Duration, value string) error + SetExpiry(ctx context.Context, key string, expiry time.Duration) (bool, error) + DeleteItem(ctx context.Context, key string) (int64, error) + HashGet(ctx context.Context, key, field string) (string, error) + HashSet(ctx context.Context, key, field, value string) error + PgGetIntItem(ctx context.Context, key string) (int64, error) +} + +// ListSecretsCacheParams holds parameters for list secrets cache operations. +type ListSecretsCacheParams struct { + ProjectID string + ActorID uuid.UUID + PermissionFingerprint string + PermissionRulesHash string + RequestParamsHash string + IfNoneMatch string +} + +// ListSecretsCacheResult represents the result of a list secrets cache check. +type ListSecretsCacheResult struct { + Response json.RawMessage + ETag string + NotModified bool +} + +// Deps holds the dependencies for the secret cache service. +type Deps struct { + KeyStore KeyStore +} + +// Service provides caching for secrets operations. +type Service struct { + logger *slog.Logger + keyStore KeyStore +} + +// NewService creates a new secret cache service. +func NewService(_ context.Context, logger *slog.Logger, deps *Deps) *Service { + return &Service{ + logger: logger.With(slog.String("service", "secretcache")), + keyStore: deps.KeyStore, + } +} + +// CheckListSecrets checks for a cached list secrets response or ETag match. +// Returns nil if no cache hit. +func (s *Service) CheckListSecrets( + ctx context.Context, + params *ListSecretsCacheParams, + cipherPair *kms.CipherPair, +) (*ListSecretsCacheResult, error) { + etagField := s.listSecretsEtagField(params) + etagKey := s.listSecretsEtagKey(params.ProjectID) + + // Check If-None-Match first (fast path) + if params.IfNoneMatch != "" { + storedEtag, err := s.keyStore.HashGet(ctx, etagKey, etagField) + if err != nil { + return nil, fmt.Errorf("getting etag from cache: %w", err) + } + if storedEtag != "" && storedEtag == params.IfNoneMatch { + return &ListSecretsCacheResult{ + NotModified: true, + ETag: params.IfNoneMatch, + }, nil + } + } + + // Get DAL version for cache key + version, err := s.keyStore.PgGetIntItem(ctx, s.listSecretsDalVersionKey(params.ProjectID)) + if err != nil { + return nil, fmt.Errorf("getting dal version from cache: %w", err) + } + + cacheKey := s.listSecretsCacheKey(params, version) + + encryptedPayload, err := s.keyStore.GetItem(ctx, cacheKey) + if err != nil { + return nil, fmt.Errorf("getting cached secrets: %w", err) + } + if encryptedPayload == "" { + return nil, nil + } + + // Decode and decrypt + cipherBytes, err := base64.StdEncoding.DecodeString(encryptedPayload) + if err != nil { + s.deleteCorruptedEntry(ctx, cacheKey) + return nil, fmt.Errorf("decoding cached payload: %w", err) + } + + plaintext, err := cipherPair.Decrypt(cipherBytes) + if err != nil { + s.deleteCorruptedEntry(ctx, cacheKey) + return nil, fmt.Errorf("decrypting cached payload: %w", err) + } + + // Refresh TTL on hit + if _, err := s.keyStore.SetExpiry(ctx, cacheKey, jitter.Apply(listSecretsCacheTTL, listSecretsCacheJitter)); err != nil { + return nil, fmt.Errorf("refreshing cache ttl: %w", err) + } + + // Compute and store ETag + etag := s.computeETag(plaintext) + if err := s.keyStore.HashSet(ctx, etagKey, etagField, etag); err != nil { + return nil, fmt.Errorf("storing etag: %w", err) + } + if _, err := s.keyStore.SetExpiry(ctx, etagKey, listSecretsEtagTTL); err != nil { + return nil, fmt.Errorf("setting etag expiry: %w", err) + } + + return &ListSecretsCacheResult{ + Response: plaintext, + ETag: etag, + }, nil +} + +// WriteListSecrets caches a list secrets response and returns the ETag. +func (s *Service) WriteListSecrets( + ctx context.Context, + params *ListSecretsCacheParams, + cipherPair *kms.CipherPair, + response any, +) (string, error) { + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", fmt.Errorf("marshaling response: %w", err) + } + + etag := s.computeETag(jsonBytes) + + version, err := s.keyStore.PgGetIntItem(ctx, s.listSecretsDalVersionKey(params.ProjectID)) + if err != nil { + return etag, fmt.Errorf("getting dal version: %w", err) + } + + etagField := s.listSecretsEtagField(params) + etagKey := s.listSecretsEtagKey(params.ProjectID) + cacheKey := s.listSecretsCacheKey(params, version) + + encrypted, err := cipherPair.Encrypt(jsonBytes) + if err != nil { + return etag, fmt.Errorf("encrypting payload: %w", err) + } + + // Only cache if under size limit + if len(encrypted) < listSecretsMaxCacheBytes { + encodedPayload := base64.StdEncoding.EncodeToString(encrypted) + if err := s.keyStore.SetItemWithExpiry(ctx, cacheKey, jitter.Apply(listSecretsCacheTTL, listSecretsCacheJitter), encodedPayload); err != nil { + return etag, fmt.Errorf("storing cached secrets: %w", err) + } + } + + // Store ETag + if err := s.keyStore.HashSet(ctx, etagKey, etagField, etag); err != nil { + return etag, fmt.Errorf("storing etag: %w", err) + } + if _, err := s.keyStore.SetExpiry(ctx, etagKey, listSecretsEtagTTL); err != nil { + return etag, fmt.Errorf("setting etag expiry: %w", err) + } + + return etag, nil +} + +// BuildListSecretsRequestParamsHash creates a hash from list secrets request parameters. +func BuildListSecretsRequestParamsHash(params map[string]any) string { + return cache.GenerateHash(params) +} + +func (s *Service) listSecretsDalVersionKey(projectID string) string { + return listSecretsCachePrefix + ":" + projectID + ":" + listSecretsTable + "-dal-version" +} + +func (s *Service) listSecretsEtagKey(projectID string) string { + return "secret-etag:" + projectID + ":" + utcDayStamp() +} + +func (s *Service) listSecretsCacheKey(params *ListSecretsCacheParams, version int64) string { + return listSecretsCachePrefix + ":" + params.ProjectID + ":" + listSecretsTable + "-dal:v" + + strconv.FormatInt(version, 10) + ":get-secrets-service-layer:" + + params.ActorID.String() + "-" + params.PermissionFingerprint + "-" + + params.PermissionRulesHash + "-" + params.RequestParamsHash +} + +func (s *Service) listSecretsEtagField(params *ListSecretsCacheParams) string { + return params.ActorID.String() + ":" + params.PermissionFingerprint + ":" + params.RequestParamsHash +} + +func (s *Service) computeETag(data []byte) string { + return `"` + cache.HashBytes(data) + `"` +} + +func (s *Service) deleteCorruptedEntry(ctx context.Context, key string) { + if _, err := s.keyStore.DeleteItem(ctx, key); err != nil { + s.logger.WarnContext(ctx, "failed to delete corrupted cache entry", slog.Any("error", err)) + } +} + +func utcDayStamp() string { + return time.Now().UTC().Format("20060102") +} diff --git a/backend-go/internal/services/secretmanager/secretfolder/lookup.go b/backend-go/internal/services/secrets/secretfolder/lookup.go similarity index 100% rename from backend-go/internal/services/secretmanager/secretfolder/lookup.go rename to backend-go/internal/services/secrets/secretfolder/lookup.go diff --git a/backend-go/internal/services/secretmanager/secretfolder/lookup_test.go b/backend-go/internal/services/secrets/secretfolder/lookup_test.go similarity index 100% rename from backend-go/internal/services/secretmanager/secretfolder/lookup_test.go rename to backend-go/internal/services/secrets/secretfolder/lookup_test.go diff --git a/backend-go/internal/services/secretmanager/secretfolder/secretfolder.go b/backend-go/internal/services/secrets/secretfolder/secretfolder.go similarity index 100% rename from backend-go/internal/services/secretmanager/secretfolder/secretfolder.go rename to backend-go/internal/services/secrets/secretfolder/secretfolder.go diff --git a/backend-go/internal/services/secretmanager/secretimport/lookup.go b/backend-go/internal/services/secrets/secretimport/lookup.go similarity index 100% rename from backend-go/internal/services/secretmanager/secretimport/lookup.go rename to backend-go/internal/services/secrets/secretimport/lookup.go diff --git a/backend-go/internal/services/secretmanager/secretimport/lookup_test.go b/backend-go/internal/services/secrets/secretimport/lookup_test.go similarity index 100% rename from backend-go/internal/services/secretmanager/secretimport/lookup_test.go rename to backend-go/internal/services/secrets/secretimport/lookup_test.go diff --git a/backend-go/internal/services/secretmanager/secretimport/secretimport.go b/backend-go/internal/services/secrets/secretimport/secretimport.go similarity index 100% rename from backend-go/internal/services/secretmanager/secretimport/secretimport.go rename to backend-go/internal/services/secrets/secretimport/secretimport.go diff --git a/backend-go/internal/services/services.go b/backend-go/internal/services/services.go new file mode 100644 index 00000000000..016a68c8da9 --- /dev/null +++ b/backend-go/internal/services/services.go @@ -0,0 +1,131 @@ +package services + +import ( + "context" + "fmt" + "log/slog" + + "github.com/redis/go-redis/v9" + + "github.com/infisical/api/internal/config" + "github.com/infisical/api/internal/database/pg" + "github.com/infisical/api/internal/ee/services/externalkms" + "github.com/infisical/api/internal/ee/services/license" + "github.com/infisical/api/internal/ee/services/ratelimit" + "github.com/infisical/api/internal/keystore" + "github.com/infisical/api/internal/queue" + "github.com/infisical/api/internal/services/assumeprivilege" + "github.com/infisical/api/internal/services/auditlog" + "github.com/infisical/api/internal/services/kms" + "github.com/infisical/api/internal/services/permission" +) + +// Infra holds the external infrastructure dependencies. +type Infra struct { + Logger *slog.Logger + Config *config.Config + DB pg.DB + Redis redis.UniversalClient + HSM kms.HsmService + License *license.Service + KeyStore keystore.KeyStore + Queue *queue.Service +} + +// Services holds all initialized services. +type Services struct { + infra *Infra + + // Top-level services (frequently used across handlers) + KMS *kms.Service + Permission *permission.Service + AuditLog *auditlog.Service + AssumePrivilege *assumeprivilege.Service + RateLimit *ratelimit.Service + License *license.Service + + // Grouped services + platform *PlatformGroup + secrets *SecretsGroup +} + +// New creates all services. +// Returns a cleanup function that should be called during graceful shutdown. +func New(ctx context.Context, infra *Infra) (*Services, func(), error) { + s := &Services{ + infra: infra, + License: infra.License, + } + + // Initialize top-level services + if err := s.initTopLevel(ctx); err != nil { + return nil, nil, err + } + + // Initialize groups + s.initPlatform(ctx) + s.initSecrets(ctx) + + cleanup := func() { + s.KMS.Close() + } + + return s, cleanup, nil +} + +func (s *Services) initTopLevel(ctx context.Context) error { + externalKmsSvc, err := externalkms.NewService(ctx, s.infra.Logger, &externalkms.Deps{}) + if err != nil { + return fmt.Errorf("external kms: %w", err) + } + + s.KMS, err = kms.NewService(ctx, s.infra.Logger, &kms.Deps{ + DB: s.infra.DB, + HSM: s.infra.HSM, + ExternalKms: externalKmsSvc, + Config: s.infra.Config, + }) + if err != nil { + return fmt.Errorf("kms: %w", err) + } + + if err = s.KMS.Start(ctx, s.infra.HSM != nil); err != nil { + return fmt.Errorf("kms start: %w", err) + } + + s.Permission = permission.NewService(ctx, s.infra.Logger, &permission.Deps{DB: s.infra.DB}) + + s.AssumePrivilege = assumeprivilege.NewService(ctx, s.infra.Logger, &assumeprivilege.Deps{ + AuthSecret: s.infra.Config.AuthSecret, + PermissionService: s.Permission, + }) + + s.AuditLog = auditlog.NewService(ctx, s.infra.Logger, &auditlog.Deps{ + Queue: s.infra.Queue, + Config: s.infra.Config, + }) + + s.RateLimit = ratelimit.NewService(ctx, s.infra.Logger, &ratelimit.Deps{ + Redis: s.infra.Redis, + LicenseSvc: s.infra.License, + IsCloud: s.infra.Config.IsCloud, + IsEnabled: s.infra.Config.IsCloud && s.infra.Config.IsProductionMode, + }) + + return nil +} + +// Infra returns the infrastructure dependencies. +func (s *Services) Infra() *Infra { + return s.infra +} + +// Platform returns the platform services group. +func (s *Services) Platform() *PlatformGroup { + return s.platform +} + +// Secrets returns the secrets services group. +func (s *Services) Secrets() *SecretsGroup { + return s.secrets +} diff --git a/backend-go/llm/TESTING_GUIDELINE.md b/backend-go/llm/TESTING_GUIDELINE.md index f1fbd83d9ae..e3346376954 100644 --- a/backend-go/llm/TESTING_GUIDELINE.md +++ b/backend-go/llm/TESTING_GUIDELINE.md @@ -23,6 +23,7 @@ Not all code needs tests. Skip tests for: ## Core Rules - Table-driven tests MUST use named subtests — every test case needs a `name` field passed to `t.Run`. +- Each test must be independent and if no co-relation you should use `t.parallel` to run tests parallel - Integration tests MUST use build tags (`//go:build integration`) to separate from unit tests. - Tests MUST NOT depend on execution order — each test MUST be independently runnable. - Packages with goroutines SHOULD use `goleak.VerifyTestMain` in `TestMain` to detect goroutine leaks. @@ -201,9 +202,31 @@ This keeps `go test ./...` fast by default. Integration tests run explicitly: make test # runs: go test -race -tags=integration ./... ``` -### Test database setup +### Layout: grouped by domain -Use the `testutil/infra` package to spin up containers. The containers are shared across tests in the same package via `TestMain`: +Integration tests live under `tests/`, grouped by domain (`tests///`), +with shared infrastructure in `tests/infra/`: + +``` +tests/ +├── infra/ # testcontainers + HTTP client (shared by all suites) +│ └── nodejs/ # Node.js backend seed facade, split by domain +├── platform/ +│ ├── auth/ # one package per domain +│ ├── kms/ +│ └── permission/ +└── secrets/ + └── secrets/ # list, get, permissions, cache, v3, service token +``` + +Each domain is its own `package _test`. Keep all tests for one domain in that package +so they share its `TestMain` (one container boot) and helpers, and tests in other domains +run as separate packages in parallel. + +### Test stack setup + +`tests/infra` spins up the containers (Postgres, Redis, the Node.js backend) once per +package via `TestMain`. Build the stack with the `infra.New()` builder: ```go //go:build integration @@ -217,7 +240,7 @@ import ( "go.uber.org/goleak" - "github.com/infisical/api/internal/testutil/infra" + "github.com/infisical/api/tests/infra" ) var stack *infra.Stack @@ -228,6 +251,8 @@ func TestMain(m *testing.M) { stack = infra.New(). WithPostgres(). WithRedis(). + WithNodeJSApi(). + WithEEFeatures("rbac", "groups", "secretApproval"). // license features the suite needs MustStart() code := m.Run() @@ -248,56 +273,38 @@ func TestMain(m *testing.M) { } ``` -### Writing an integration test +### Per-test isolation + seed facade -Each test gets a clean transaction that rolls back at the end, so tests never pollute each other: +The stack is shared, so isolation comes from each test seeding its **own** project (and +identities/secrets under it) rather than transaction rollback. Seed through the +`tests/infra/nodejs` facade — `stack.NodeJS().For(t)` — which drives the real Node.js API +with domain builders (required args in the constructor, optional setters, terminal `Do()`): ```go -func TestCreateSecret_PersistsToDatabase(t *testing.T) { - db := testutil.AcquireDB(t) // returns a pg.DB scoped to a rolled-back tx - - svc := secrets.NewService(context.Background(), testutil.Logger(t), &secrets.Deps{ - DB: db, - }) - - created, err := svc.CreateSecret(context.Background(), secrets.CreateOpts{ - FolderID: testutil.SeedFolder(t, db, "production", "/"), - Key: "DB_PASSWORD", - Value: []byte("hunter2"), - }) - - require.NoError(t, err) - assert.Equal(t, "DB_PASSWORD", created.Key) - - // Verify it's readable - fetched, err := svc.GetSecretByName(context.Background(), secrets.GetByNameOpts{ - FolderID: created.FolderID, - Key: "DB_PASSWORD", - }) - require.NoError(t, err) - assert.Equal(t, created.ID, fetched.ID) -} -``` - -### Seed helpers - -Create small helper functions for common test data setup. These belong in `testutil/` or as unexported helpers in the test file: - -```go -// testutil/seeds.go -func SeedFolder(t *testing.T, db pg.DB, env, path string) uuid.UUID { - t.Helper() - id := uuid.New() - _, err := db.Primary().Exec(context.Background(), - `INSERT INTO secret_folders (id, environment, path) VALUES (@id, @env, @path)`, - pgx.NamedArgs{"id": id, "env": env, "path": path}, - ) - require.NoError(t, err) - return id +func TestListSecrets_ReturnsCreatedSecret(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-secrets").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "DB_PASSWORD", "hunter2"). + Comment("primary db"). + Do() + + identity := api.Identities.Create("list-secrets-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + // ... exercise the Go handler against the seeded project ... } ``` -Always call `t.Helper()` so failure messages point to the calling test, not the seed function. +Because every test owns a freshly-named project, suites run with `t.Parallel()` without +cross-contamination. Add new seed endpoints as builders in the matching `tests/infra/nodejs` +domain file (e.g. `secrets.go`, `identities.go`) so new params never break existing call +sites. ## Goroutine Leak Detection diff --git a/backend-go/tests/infra/builder.go b/backend-go/tests/infra/builder.go index 6bdab1cc988..f5b55359bca 100644 --- a/backend-go/tests/infra/builder.go +++ b/backend-go/tests/infra/builder.go @@ -14,6 +14,7 @@ import ( "github.com/infisical/api/internal/config" "github.com/infisical/api/internal/database/pg" + "github.com/infisical/api/tests/infra/nodejs" ) // licenseFeaturePath is the path to the compiled license-fns module inside the Node.js container. @@ -123,7 +124,16 @@ func (b *Builder) MustStart() *Stack { // 3. Start NodeJS after Postgres and Redis are healthy. if b.wantNodeJS { - stack.nodejs, err = startNodeJS(ctx, net.Name, b.nodeJSFiles, b.buildNodeJSCmd()) + stack.nodeJS, err = nodejs.Start(ctx, &nodejs.Config{ + NetworkName: net.Name, + Files: b.nodeJSFiles, + Cmd: b.buildNodeJSCmd(), + DBUser: pgUser, + DBPassword: pgPassword, + DBName: pgDB, + EncryptionKey: EncryptionKey, + AuthSecret: AuthSecret, + }) if err != nil { log.Fatalf("infra: %v", err) } @@ -148,12 +158,12 @@ func (b *Builder) MustStart() *Stack { // 7. Bootstrap admin user/org/identity via the NodeJS API. if b.wantNodeJS { - stack.nodejs.bootstrap() + stack.nodeJS.Bootstrap() } - // 8. Give NodeJSService access to DB for seeding helpers (e.g. toggling email verified). + // 8. Give the service a DB handle for seeding helpers (e.g. reading user IDs). if b.wantNodeJS && stack.db != nil { - stack.nodejs.db = stack.db + stack.nodeJS.AttachDB(stack.db) } log.Println("infra: stack ready") diff --git a/backend-go/tests/infra/http.go b/backend-go/tests/infra/http.go new file mode 100644 index 00000000000..1916769e705 --- /dev/null +++ b/backend-go/tests/infra/http.go @@ -0,0 +1,324 @@ +//go:build integration + +package infra + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/services/auth" +) + +// ClientBuilder constructs an HTTPClient with extensible configuration. +type ClientBuilder struct { + t *testing.T + router http.Handler + identity *TestIdentity + defaultHeaders http.Header +} + +// NewClientBuilder creates a new client builder for the given router. +func NewClientBuilder(t *testing.T, router http.Handler) *ClientBuilder { + t.Helper() + return &ClientBuilder{ + t: t, + router: router, + defaultHeaders: make(http.Header), + } +} + +// Identity sets the test identity for authentication. +func (b *ClientBuilder) Identity(identity *TestIdentity) *ClientBuilder { + b.identity = identity + return b +} + +// Header adds a default header to all requests. +func (b *ClientBuilder) Header(key, value string) *ClientBuilder { + b.defaultHeaders.Set(key, value) + return b +} + +// Build creates the HTTPClient and starts the test server. +func (b *ClientBuilder) Build() *HTTPClient { + b.t.Helper() + + handler := b.router + if b.identity != nil { + handler = applyTestIdentity(b.identity, handler) + } + + srv := httptest.NewServer(handler) + b.t.Cleanup(func() { srv.Close() }) + + return &HTTPClient{ + t: b.t, + srv: srv, + defaultHeaders: b.defaultHeaders.Clone(), + } +} + +func applyTestIdentity(identity *TestIdentity, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithIdentity(r.Context(), identity.ToAuthIdentity()) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// HTTPClient wraps an httptest.Server for fluent request building. +type HTTPClient struct { + t *testing.T + srv *httptest.Server + defaultHeaders http.Header +} + +// Get creates a GET request builder. +func (c *HTTPClient) Get(path string) *Request { + return c.newRequest(http.MethodGet, path) +} + +// Post creates a POST request builder. +func (c *HTTPClient) Post(path string) *Request { + return c.newRequest(http.MethodPost, path) +} + +// Put creates a PUT request builder. +func (c *HTTPClient) Put(path string) *Request { + return c.newRequest(http.MethodPut, path) +} + +// Patch creates a PATCH request builder. +func (c *HTTPClient) Patch(path string) *Request { + return c.newRequest(http.MethodPatch, path) +} + +// Delete creates a DELETE request builder. +func (c *HTTPClient) Delete(path string) *Request { + return c.newRequest(http.MethodDelete, path) +} + +func (c *HTTPClient) newRequest(method, path string) *Request { + return &Request{ + t: c.t, + srv: c.srv, + method: method, + path: path, + params: url.Values{}, + headers: c.defaultHeaders.Clone(), + } +} + +// Request builds an HTTP request with fluent methods. +type Request struct { + t *testing.T + srv *httptest.Server + method string + path string + params url.Values + headers http.Header + body any +} + +// Param adds a query parameter. +func (r *Request) Param(key, value string) *Request { + r.params.Set(key, value) + return r +} + +// ParamIf adds a query parameter if the value is non-nil. +func (r *Request) ParamIf(key string, value *string) *Request { + if value != nil { + r.params.Set(key, *value) + } + return r +} + +// ParamBool adds a boolean query parameter if non-nil. +func (r *Request) ParamBool(key string, value *bool) *Request { + if value != nil { + r.params.Set(key, strconv.FormatBool(*value)) + } + return r +} + +// ParamInt adds an integer query parameter if non-nil. +func (r *Request) ParamInt(key string, value *int) *Request { + if value != nil { + r.params.Set(key, strconv.Itoa(*value)) + } + return r +} + +// Params encodes a struct's json-tagged fields as query parameters. Field names +// are taken from the json tag, so request structs generated from the OpenAPI +// spec (e.g. secret.ListSecretsV4Query) stay in sync with the wire contract +// automatically: a new spec param shows up as a struct field with no helper +// edit. Nil pointer fields are omitted (matching omitempty wire semantics); +// every other field is set, including required zero values. +// +// Use raw Param/ParamInt/ParamBool for negative tests that need to send invalid +// or omitted-required input, which a typed struct cannot express. +func (r *Request) Params(v any) *Request { + r.t.Helper() + + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return r + } + rv = rv.Elem() + } + require.Equal(r.t, reflect.Struct, rv.Kind(), "Params requires a struct or pointer to struct") + + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + if !field.IsExported() { + continue + } + + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + + fv := rv.Field(i) + if fv.Kind() == reflect.Pointer { + if fv.IsNil() { + continue + } + fv = fv.Elem() + } + + r.params.Set(name, fmt.Sprintf("%v", fv.Interface())) + } + + return r +} + +// Header sets a request header. +func (r *Request) Header(key, value string) *Request { + r.headers.Set(key, value) + return r +} + +// Body sets the request body. Structs/maps are JSON-encoded; []byte and +// json.RawMessage are sent verbatim (so a generated request struct round-trips +// through its own json tags, and raw payloads are not double-encoded). +func (r *Request) Body(v any) *Request { + r.body = v + return r +} + +// RawBody sets a verbatim request body without JSON encoding. Use for negative +// tests that send malformed JSON (e.g. RawBody([]byte(`{"secretValue":`))). +func (r *Request) RawBody(b []byte) *Request { + r.body = json.RawMessage(b) + return r +} + +// Do executes the request and returns raw body, status code, and response headers. +func (r *Request) Do() (body []byte, status int, header http.Header) { + r.t.Helper() + + fullPath := r.path + if len(r.params) > 0 { + fullPath += "?" + r.params.Encode() + } + + var bodyReader io.Reader = http.NoBody + if r.body != nil { + switch b := r.body.(type) { + case json.RawMessage: + bodyReader = bytes.NewReader(b) + case []byte: + bodyReader = bytes.NewReader(b) + default: + bodyBytes, err := json.Marshal(r.body) + require.NoError(r.t, err, "failed to marshal request body") + bodyReader = bytes.NewReader(bodyBytes) + } + } + + req, err := http.NewRequestWithContext(r.t.Context(), r.method, r.srv.URL+fullPath, bodyReader) + require.NoError(r.t, err, "failed to create request") + + if r.body != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range r.headers { + req.Header[k] = v + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(r.t, err, "failed to execute request") + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + r.t.Logf("closing response body: %v", cerr) + } + }() + + body, err = io.ReadAll(resp.Body) + require.NoError(r.t, err, "failed to read response body") + + return body, resp.StatusCode, resp.Header +} + +// Into executes the request and unmarshals the response into v. +// Returns an error if status >= 400. +func (r *Request) Into(v any) error { + r.t.Helper() + + body, status, _ := r.Do() + + if status >= 400 { + var errResp struct { + Message string `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errResp); err != nil { + return errors.New(string(body)) + } + msg := errResp.Message + if msg == "" { + msg = errResp.Error + } + if msg == "" { + msg = string(body) + } + return errors.New(msg) + } + + if v != nil { + return json.Unmarshal(body, v) + } + return nil +} + +// MustInto executes the request and unmarshals the response into v. +// Fails the test if status >= 400 or unmarshal fails. +func (r *Request) MustInto(v any) { + r.t.Helper() + require.NoError(r.t, r.Into(v)) +} + +// ExpectStatus executes the request and asserts the expected status code. +// Returns the response body for further inspection if needed. +func (r *Request) ExpectStatus(expectedStatus int) []byte { + r.t.Helper() + body, status, _ := r.Do() + require.Equal(r.t, expectedStatus, status, "unexpected status code: %s", string(body)) + return body +} diff --git a/backend-go/tests/infra/identity.go b/backend-go/tests/infra/identity.go new file mode 100644 index 00000000000..f7546ac7b09 --- /dev/null +++ b/backend-go/tests/infra/identity.go @@ -0,0 +1,135 @@ +//go:build integration + +package infra + +import ( + "github.com/google/uuid" + + "github.com/infisical/api/internal/services/auth" + "github.com/infisical/api/tests/infra/nodejs" +) + +// TestIdentity represents authentication context for test requests. +// Add new fields as needed without breaking existing callers. +type TestIdentity struct { + AuthMode auth.AuthMode + ActorType auth.ActorType + ActorID uuid.UUID + OrgID uuid.UUID + RootOrgID uuid.UUID + ParentOrgID uuid.UUID + + // User-specific + UserEmail string + Username string + IsMfaVerified bool + + // Identity-specific + IdentityName string + IdentityAuthMethod auth.IdentityAuthMethod + + // Service token specific (reuses ServiceTokenScope from the nodejs package) + ServiceTokenScopes []nodejs.ServiceTokenScope +} + +// ToAuthIdentity converts TestIdentity to the auth.Identity used by handlers. +func (i *TestIdentity) ToAuthIdentity() *auth.Identity { + identity := &auth.Identity{ + AuthMode: i.AuthMode, + Actor: i.ActorType, + ActorID: i.ActorID, + OrgID: i.OrgID, + RootOrgID: i.RootOrgID, + ParentOrgID: i.ParentOrgID, + IsMfaVerified: i.IsMfaVerified, + } + + if i.ActorType == auth.ActorTypeUser { + identity.UserAuthInfo = &auth.UserAuthInfo{ + UserID: i.ActorID, + Email: i.UserEmail, + } + identity.Email = i.UserEmail + identity.Username = i.Username + } + + if i.ActorType == auth.ActorTypeIdentity { + identity.Name = i.IdentityName + identity.IdentityAuthInfo = &auth.AuthInfo{ + IdentityID: i.ActorID, + IdentityName: i.IdentityName, + AuthMethod: i.IdentityAuthMethod, + } + } + + return identity +} + +// UserIdentity creates a test identity for a user (JWT auth). +func UserIdentity(userID, orgID string) *TestIdentity { + return &TestIdentity{ + AuthMode: auth.AuthModeJWT, + ActorType: auth.ActorTypeUser, + ActorID: uuid.MustParse(userID), + OrgID: uuid.MustParse(orgID), + } +} + +// MachineIdentity creates a test identity for a machine identity (access token auth). +func MachineIdentity(identityID, orgID string) *TestIdentity { + return &TestIdentity{ + AuthMode: auth.AuthModeIdentityAccessToken, + ActorType: auth.ActorTypeIdentity, + ActorID: uuid.MustParse(identityID), + OrgID: uuid.MustParse(orgID), + } +} + +// ServiceTokenIdentity creates a test identity for a service token. +func ServiceTokenIdentity(tokenID, orgID string, scopes ...nodejs.ServiceTokenScope) *TestIdentity { + return &TestIdentity{ + AuthMode: auth.AuthModeServiceToken, + ActorType: auth.ActorTypeService, + ActorID: uuid.MustParse(tokenID), + OrgID: uuid.MustParse(orgID), + ServiceTokenScopes: scopes, + } +} + +// Chainable modifiers for TestIdentity + +// WithEmail sets the user email. +func (i *TestIdentity) WithEmail(email string) *TestIdentity { + i.UserEmail = email + return i +} + +// WithUsername sets the username. +func (i *TestIdentity) WithUsername(username string) *TestIdentity { + i.Username = username + return i +} + +// WithIdentityName sets the machine identity name. +func (i *TestIdentity) WithIdentityName(name string) *TestIdentity { + i.IdentityName = name + return i +} + +// WithIdentityAuthMethod sets the identity auth method. +func (i *TestIdentity) WithIdentityAuthMethod(method auth.IdentityAuthMethod) *TestIdentity { + i.IdentityAuthMethod = method + return i +} + +// WithMfaVerified sets the MFA verified flag. +func (i *TestIdentity) WithMfaVerified(verified bool) *TestIdentity { + i.IsMfaVerified = verified + return i +} + +// WithRootOrgID sets the root org ID. +func (i *TestIdentity) WithRootOrgID(rootOrgID string) *TestIdentity { + i.RootOrgID = uuid.MustParse(rootOrgID) + return i +} diff --git a/backend-go/tests/infra/nodejs.go b/backend-go/tests/infra/nodejs.go deleted file mode 100644 index 07d1294fb9f..00000000000 --- a/backend-go/tests/infra/nodejs.go +++ /dev/null @@ -1,1031 +0,0 @@ -//go:build integration - -package infra - -import ( - "context" - "crypto/rand" - "fmt" - "log" - "testing" - "time" - - "github.com/go-resty/resty/v2" - "github.com/google/uuid" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" - - "github.com/infisical/api/internal/database/pg" -) - -// ProjectSeed contains IDs for a project created via the Node.js API. -type ProjectSeed struct { - ID string - Slug string - EnvSlug string -} - -// IdentitySeed contains IDs for a machine identity created via the Node.js API. -type IdentitySeed struct { - ID string - Name string -} - -// CustomRoleSeed contains metadata for a custom project role created via the Node.js API. -type CustomRoleSeed struct { - ID string - Slug string - Name string -} - -// GroupSeed contains metadata for an org group created via the Node.js API. -type GroupSeed struct { - ID string - Name string - Slug string -} - -// UserSeed contains IDs for a user created via the Node.js API. -type UserSeed struct { - ID string - Email string - Token string // JWT access token for authentication -} - -// NodeJSService provides access to a running Node.js backend container -// and the bootstrapped credentials (admin user, org, machine identity). -type NodeJSService struct { - container testcontainers.Container - url string - client *resty.Client - db pg.DB - orgID string - userID string - userEmail string - identityToken string - userToken string -} - -func (n *NodeJSService) URL() string { return n.url } -func (n *NodeJSService) OrgID() string { return n.orgID } -func (n *NodeJSService) UserID() string { return n.userID } -func (n *NodeJSService) UserEmail() string { return n.userEmail } -func (n *NodeJSService) IdentityToken() string { return n.identityToken } -func (n *NodeJSService) UserToken() string { return n.userToken } -func (n *NodeJSService) Client() *resty.Client { return n.client } - -func startNodeJS(ctx context.Context, networkName string, files []testcontainers.ContainerFile, cmd []string) (*NodeJSService, error) { - // When a custom Cmd is provided (e.g. for patching files via sed), we need - // to run as root because the container image sets USER non-root-user which - // cannot write to root-owned paths like /backend/dist/. - user := "" - if len(cmd) > 0 { - user = "root" - } - - req := testcontainers.ContainerRequest{ - Image: "infisical/infisical:latest", - ExposedPorts: []string{"8080/tcp"}, - Networks: []string{networkName}, - NetworkAliases: map[string][]string{ - networkName: {"backend-nodejs"}, - }, - User: user, - Env: map[string]string{ - "NODE_ENV": "development", - "DB_CONNECTION_URI": fmt.Sprintf("postgres://%s:%s@db:5432/%s?sslmode=disable", pgUser, pgPassword, pgDB), - "REDIS_URL": "redis://redis:6379", - "ENCRYPTION_KEY": EncryptionKey, - "AUTH_SECRET": AuthSecret, - "SITE_URL": "http://localhost:8080", - "TELEMETRY_ENABLED": "false", - "SMTP_HOST": "", - }, - Files: files, - Cmd: cmd, - WaitingFor: wait.ForHTTP("/api/status").WithPort("8080/tcp").WithStartupTimeout(120 * time.Second), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - return nil, fmt.Errorf("starting nodejs: %w", err) - } - - host, err := container.Host(ctx) - if err != nil { - return nil, fmt.Errorf("getting nodejs host: %w", err) - } - - mappedPort, err := container.MappedPort(ctx, "8080/tcp") - if err != nil { - return nil, fmt.Errorf("getting nodejs port: %w", err) - } - - baseURL := fmt.Sprintf("http://%s:%d", host, mappedPort.Int()) - - return &NodeJSService{ - container: container, - url: baseURL, - client: resty.New().SetBaseURL(baseURL), - }, nil -} - -// bootstrap creates the initial admin user, org, and machine identity, -// then logs in to obtain a user JWT. Uses log.Fatalf since it runs in TestMain. -func (n *NodeJSService) bootstrap() { - var bootstrapResp BootstrapResponse - resp, err := n.client.R(). - SetBody(BootstrapRequest{ - Email: "test-admin@example.com", - Password: "testpassword123", - Organization: "test-org", - }). - SetResult(&bootstrapResp). - Post("/api/v1/admin/bootstrap") - if err != nil { - log.Fatalf("infra.bootstrap: request failed: %v", err) - } - if resp.IsError() { - log.Fatalf("infra.bootstrap: returned %d: %s", resp.StatusCode(), resp.String()) - } - - n.orgID = bootstrapResp.Organization.ID - n.identityToken = bootstrapResp.Identity.Credentials.Token - n.userEmail = bootstrapResp.User.Email - n.userID = bootstrapResp.User.ID - - var loginResp LoginResponse - resp, err = n.client.R(). - SetBody(LoginRequest{ - Email: n.userEmail, - Password: "testpassword123", - }). - SetResult(&loginResp). - Post("/api/v3/auth/login") - if err != nil { - log.Fatalf("infra.bootstrap: login request failed: %v", err) - } - if resp.IsError() { - log.Fatalf("infra.bootstrap: login returned %d: %s", resp.StatusCode(), resp.String()) - } - - // Select organization to get an org-scoped JWT (required for org-level API calls). - var selectOrgResp SelectOrgResponse - resp, err = n.client.R(). - SetHeader("Authorization", "Bearer "+loginResp.AccessToken). - SetBody(SelectOrgRequest{ - OrganizationID: n.orgID, - }). - SetResult(&selectOrgResp). - Post("/api/v3/auth/select-organization") - if err != nil { - log.Fatalf("infra.bootstrap: select-org request failed: %v", err) - } - if resp.IsError() { - log.Fatalf("infra.bootstrap: select-org returned %d: %s", resp.StatusCode(), resp.String()) - } - n.userToken = selectOrgResp.Token -} - -// MustCreateProject creates a new project via the Node.js API. -// Safe to call from TestMain — uses log.Fatalf on error. -func (n *NodeJSService) MustCreateProject(name string) *ProjectSeed { - var projectResp CreateProjectResponse - resp, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateProjectRequest{ - ProjectName: name, - Slug: fmt.Sprintf("test-%s", name), - Type: "secret-manager", - }). - SetResult(&projectResp). - Post("/api/v1/projects") - if err != nil { - log.Fatalf("infra.MustCreateProject: request failed: %v", err) - } - if resp.IsError() { - log.Fatalf("infra.MustCreateProject: returned %d: %s", resp.StatusCode(), resp.String()) - } - - return &ProjectSeed{ - ID: projectResp.Project.ID, - Slug: projectResp.Project.Slug, - EnvSlug: "dev", - } -} - -// CreateProject creates a new project via the Node.js API and returns its metadata. -// The bootstrap identity creates the project (automatically becoming admin), then the -// bootstrap user is also added as admin so both can be used for API calls. -func (n *NodeJSService) CreateProject(t *testing.T, name string) *ProjectSeed { - t.Helper() - - b := make([]byte, 4) - rand.Read(b) - slug := fmt.Sprintf("t-%s-%x", name, b) - if len(slug) > 36 { - slug = slug[:36] - } - - var projectResp CreateProjectResponse - resp, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateProjectRequest{ - ProjectName: name, - Slug: slug, - Type: "secret-manager", - }). - SetResult(&projectResp). - Post("/api/v1/projects") - if err != nil { - t.Fatalf("infra.CreateProject: request failed: %v", err) - } - if resp.IsError() { - t.Fatalf("infra.CreateProject: returned %d: %s", resp.StatusCode(), resp.String()) - } - - projectID := projectResp.Project.ID - - // Add bootstrap user to project as admin (identity is already admin as creator) - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(AddUserToProjectRequest{ - Usernames: []string{n.userEmail}, - RoleSlugs: []string{"admin"}, - }). - Post(fmt.Sprintf("/api/v1/projects/%s/memberships", projectID)) - if err != nil { - t.Fatalf("infra.CreateProject: add user failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateProject: add user returned %d: %s", r.StatusCode(), r.String()) - } - - return &ProjectSeed{ - ID: projectID, - Slug: projectResp.Project.Slug, - EnvSlug: "dev", - } -} - -// DeleteProject deletes a project via the Node.js API. -func (n *NodeJSService) DeleteProject(t *testing.T, projectID string) { - t.Helper() - - resp, err := n.client.R(). - SetAuthToken(n.identityToken). - Delete("/api/v1/projects/" + projectID) - if err != nil { - t.Fatalf("infra.DeleteProject: request failed: %v", err) - } - if resp.IsError() { - t.Fatalf("infra.DeleteProject: returned %d: %s", resp.StatusCode(), resp.String()) - } -} - -// CreateIdentity creates a new machine identity in the bootstrap org. -func (n *NodeJSService) CreateIdentity(t *testing.T, name string) *IdentitySeed { - t.Helper() - - var resp CreateIdentityResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateIdentityRequest{ - Name: name, - OrganizationID: n.orgID, - Role: "no-access", - }). - SetResult(&resp). - Post("/api/v1/identities") - if err != nil { - t.Fatalf("infra.CreateIdentity: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateIdentity: returned %d: %s", r.StatusCode(), r.String()) - } - - return &IdentitySeed{ - ID: resp.Identity.ID, - Name: name, - } -} - -// DeleteIdentity deletes a machine identity. This also revokes all tokens for the identity. -func (n *NodeJSService) DeleteIdentity(t *testing.T, identityID string) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.identityToken). - Delete("/api/v1/identities/" + identityID) - if err != nil { - t.Fatalf("infra.DeleteIdentity: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.DeleteIdentity: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// AddIdentityToProject adds a machine identity to a project with the given roles. -// Each role entry can include temporary access fields (isTemporary, temporaryMode, temporaryRange, -// temporaryAccessStartTime). For simple cases, use infra.Role("admin") helper. -func (n *NodeJSService) AddIdentityToProject(t *testing.T, projectID, identityID string, roles []RoleAssignment) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(AddIdentityToProjectWithRolesRequest{ - Roles: roles, - }). - Post(fmt.Sprintf("/api/v1/projects/%s/memberships/identities/%s", projectID, identityID)) - if err != nil { - t.Fatalf("infra.AddIdentityToProject: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.AddIdentityToProject: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// Role is a helper to create a simple RoleAssignment for AddIdentityToProject. -func Role(slug string) []RoleAssignment { - return []RoleAssignment{{Role: slug}} -} - -// InviteAndCreateUser invites a user to the org and returns their seed. -// The invite creates a user record + org membership in the DB. -// The user ID is queried from the DB since the invite response doesn't include it. -func (n *NodeJSService) InviteAndCreateUser(t *testing.T, email string) *UserSeed { - t.Helper() - - // 1. Invite user to org (requires user JWT auth with org context) - r, err := n.client.R(). - SetAuthToken(n.userToken). - SetBody(InviteToOrgRequest{ - InviteeEmails: []string{email}, - OrganizationID: n.orgID, - }). - Post("/api/v1/invite-org/signup") - if err != nil { - t.Fatalf("infra.InviteAndCreateUser: invite failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.InviteAndCreateUser: invite returned %d: %s", r.StatusCode(), r.String()) - } - - // 2. Query user ID from DB (invite creates a user record with the email as username) - if n.db == nil { - t.Fatal("infra.InviteAndCreateUser: db is nil, cannot query user ID") - } - var userID string - err = n.db.Primary().QueryRow(context.Background(), - `SELECT id FROM users WHERE username = $1`, email).Scan(&userID) - if err != nil { - t.Fatalf("infra.InviteAndCreateUser: query user ID: %v", err) - } - - return &UserSeed{ - ID: userID, - Email: email, - } -} - -// AddUserToProject adds a user (by email) to a project with the given role slugs. -func (n *NodeJSService) AddUserToProject(t *testing.T, projectID, email string, roleSlugs []string) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(AddUserToProjectRequest{ - Usernames: []string{email}, - RoleSlugs: roleSlugs, - }). - Post(fmt.Sprintf("/api/v1/projects/%s/memberships", projectID)) - if err != nil { - t.Fatalf("infra.AddUserToProject: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.AddUserToProject: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// CreateCustomProjectRole creates a custom project role with the given permissions. -// Requires the "rbac" EE feature to be enabled (see WithEEFeatures). -func (n *NodeJSService) CreateCustomProjectRole(t *testing.T, projectID, slug, name string, permissions []Permission) *CustomRoleSeed { - t.Helper() - - var resp CreateCustomRoleResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateCustomRoleRequest{ - Slug: slug, - Name: name, - Permissions: permissions, - }). - SetResult(&resp). - Post(fmt.Sprintf("/api/v1/projects/%s/roles", projectID)) - if err != nil { - t.Fatalf("infra.CreateCustomProjectRole: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateCustomProjectRole: returned %d: %s", r.StatusCode(), r.String()) - } - - return &CustomRoleSeed{ - ID: resp.Role.ID, - Slug: resp.Role.Slug, - Name: resp.Role.Name, - } -} - -// CreateGroup creates an org-level group via the Node.js API. -// Requires the "groups" EE feature to be enabled (see WithEEFeatures). -func (n *NodeJSService) CreateGroup(t *testing.T, name string) *GroupSeed { - t.Helper() - - var resp CreateGroupResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateGroupRequest{ - Name: name, - Role: "no-access", - }). - SetResult(&resp). - Post("/api/v1/groups") - if err != nil { - t.Fatalf("infra.CreateGroup: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateGroup: returned %d: %s", r.StatusCode(), r.String()) - } - - return &GroupSeed{ - ID: resp.ID, - Name: resp.Name, - Slug: resp.Slug, - } -} - -// AddUserToGroup adds a user (by username/email) to a group. -// Uses the admin user JWT (not identity token) because group member management -// requires org-level group permissions with privilege boundary checks. -func (n *NodeJSService) AddUserToGroup(t *testing.T, groupID, username string) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.userToken). - SetHeader("Content-Type", "application/json"). - SetBody(struct{}{}). - Post(fmt.Sprintf("/api/v1/groups/%s/users/%s", groupID, username)) - if err != nil { - t.Fatalf("infra.AddUserToGroup: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.AddUserToGroup: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// AddGroupToProject adds a group to a project with the given role. -func (n *NodeJSService) AddGroupToProject(t *testing.T, projectID, groupID, role string) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(AddGroupToProjectRequest{ - Role: role, - }). - Post(fmt.Sprintf("/api/v1/projects/%s/memberships/groups/%s", projectID, groupID)) - if err != nil { - t.Fatalf("infra.AddGroupToProject: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.AddGroupToProject: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// IdentityPrivilegeOpts holds optional temporal parameters for CreateIdentityAdditionalPrivilege. -type IdentityPrivilegeOpts struct { - TemporaryRange string // Duration string (e.g. "1h", "30s") - TemporaryAccessStartTime string // ISO-8601 datetime string -} - -// CreateIdentityAdditionalPrivilege creates an additional privilege for an identity in a project. -// Pass nil opts for permanent privilege, or provide temporal fields for temporary privilege. -func (n *NodeJSService) CreateIdentityAdditionalPrivilege(t *testing.T, identityID, projectID string, permissions []Permission, opts *IdentityPrivilegeOpts) { - t.Helper() - - privType := PrivilegeType{IsTemporary: false} - if opts != nil && opts.TemporaryRange != "" { - privType = PrivilegeType{ - IsTemporary: true, - TemporaryMode: "relative", - TemporaryRange: opts.TemporaryRange, - TemporaryAccessStartTime: opts.TemporaryAccessStartTime, - } - } - - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateIdentityPrivilegeRequest{ - IdentityID: identityID, - ProjectID: projectID, - Permissions: permissions, - Type: privType, - }). - Post("/api/v2/identity-project-additional-privilege/") - if err != nil { - t.Fatalf("infra.CreateIdentityAdditionalPrivilege: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateIdentityAdditionalPrivilege: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// CreateUserAdditionalPrivilege creates a permanent additional privilege -// for a user in a project via the Node.js API. -// Requires looking up the project membership ID from the DB. -func (n *NodeJSService) CreateUserAdditionalPrivilege(t *testing.T, userID, projectID string, permissions []Permission) { - t.Helper() - - if n.db == nil { - t.Fatal("infra.CreateUserAdditionalPrivilege: db is nil") - } - - // Look up the user's project membership ID from the unified memberships table. - var membershipID string - err := n.db.Primary().QueryRow(context.Background(), - `SELECT id FROM memberships WHERE "actorUserId" = $1 AND "scopeProjectId" = $2 AND scope = 'project'`, - userID, projectID).Scan(&membershipID) - if err != nil { - t.Fatalf("infra.CreateUserAdditionalPrivilege: query membership ID: %v", err) - } - - r, err := n.client.R(). - SetAuthToken(n.userToken). - SetBody(CreateUserPrivilegeRequest{ - ProjectMembershipID: membershipID, - Permissions: permissions, - Type: PrivilegeType{IsTemporary: false}, - }). - Post("/api/v1/user-project-additional-privilege/") - if err != nil { - t.Fatalf("infra.CreateUserAdditionalPrivilege: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateUserAdditionalPrivilege: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// SecretSeed contains metadata for a secret created via the Node.js API. -type SecretSeed struct { - ID string - Key string - Value string - Version int -} - -// CreateSecretOpts holds optional parameters for CreateSecret. -type CreateSecretOpts struct { - Comment string - Metadata []SecretMetadataEntry - TagIDs []string - Type string // "shared" or "personal", defaults to "shared" - ReminderNote string - ReminderRepeatDays *int -} - -// CreateSecret creates a secret via the Node.js API. -// Pass nil opts for a basic shared secret, or provide opts for tags, comment, metadata, or personal type. -func (n *NodeJSService) CreateSecret(t *testing.T, projectID, environment, secretPath, key, value string, opts *CreateSecretOpts) *SecretSeed { - t.Helper() - - var comment string - var metadata []SecretMetadataEntry - var tagIDs []string - var reminderNote string - var reminderRepeatDays *int - secretType := "shared" - - if opts != nil { - comment = opts.Comment - metadata = opts.Metadata - tagIDs = opts.TagIDs - reminderNote = opts.ReminderNote - reminderRepeatDays = opts.ReminderRepeatDays - if opts.Type != "" { - secretType = opts.Type - } - } - - token := n.identityToken - if secretType == "personal" { - token = n.userToken - } - - var resp CreateSecretResponse - r, err := n.client.R(). - SetAuthToken(token). - SetBody(CreateSecretRequest{ - ProjectID: projectID, - Environment: environment, - SecretPath: secretPath, - SecretValue: value, - SecretComment: comment, - SecretMetadata: metadata, - Type: secretType, - TagIDs: tagIDs, - SecretReminderNote: reminderNote, - SecretReminderRepeatDays: reminderRepeatDays, - }). - SetResult(&resp). - Post(fmt.Sprintf("/api/v4/secrets/%s", key)) - if err != nil { - t.Fatalf("infra.CreateSecret: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateSecret: returned %d: %s", r.StatusCode(), r.String()) - } - - return &SecretSeed{ - ID: resp.Secret.ID, - Key: key, - Value: value, - Version: 1, - } -} - -// FolderSeed contains metadata for a folder created via the Node.js API. -type FolderSeed struct { - ID string - Name string -} - -// CreateFolder creates a secret folder via the Node.js API. -func (n *NodeJSService) CreateFolder(t *testing.T, projectID, environment, path, name string) *FolderSeed { - t.Helper() - - var resp CreateFolderResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateFolderRequest{ - ProjectID: projectID, - Environment: environment, - Path: path, - Name: name, - }). - SetResult(&resp). - Post("/api/v2/folders") - if err != nil { - t.Fatalf("infra.CreateFolder: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateFolder: returned %d: %s", r.StatusCode(), r.String()) - } - - return &FolderSeed{ - ID: resp.Folder.ID, - Name: name, - } -} - -// SecretImportSeed contains metadata for a secret import created via the Node.js API. -type SecretImportSeed struct { - ID string -} - -// CreateSecretImport creates a secret import via the Node.js API. -func (n *NodeJSService) CreateSecretImport(t *testing.T, projectID, environment, path, importEnv, importPath string) *SecretImportSeed { - t.Helper() - - var resp CreateSecretImportResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateSecretImportRequest{ - ProjectID: projectID, - Environment: environment, - Path: path, - Import: SecretImportTarget{ - Environment: importEnv, - Path: importPath, - }, - }). - SetResult(&resp). - Post("/api/v2/secret-imports") - if err != nil { - t.Fatalf("infra.CreateSecretImport: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateSecretImport: returned %d: %s", r.StatusCode(), r.String()) - } - - return &SecretImportSeed{ - ID: resp.SecretImport.ID, - } -} - -// EnvironmentSeed contains metadata for an environment created via the Node.js API. -type EnvironmentSeed struct { - ID string - Slug string - Name string -} - -// CreateEnvironment creates an environment in a project via the Node.js API. -func (n *NodeJSService) CreateEnvironment(t *testing.T, projectID, slug, name string) *EnvironmentSeed { - t.Helper() - - var resp CreateEnvironmentResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateEnvironmentRequest{ - Slug: slug, - Name: name, - }). - SetResult(&resp). - Post(fmt.Sprintf("/api/v1/projects/%s/environments", projectID)) - if err != nil { - t.Fatalf("infra.CreateEnvironment: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateEnvironment: returned %d: %s", r.StatusCode(), r.String()) - } - - return &EnvironmentSeed{ - ID: resp.Environment.ID, - Slug: slug, - Name: name, - } -} - -// SoftDeleteEnvironment soft-deletes an environment in a project via the Node.js API. -// The environment is marked for deletion but not immediately removed, allowing for restore. -func (n *NodeJSService) SoftDeleteEnvironment(t *testing.T, projectID, envID string) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.identityToken). - Delete(fmt.Sprintf("/api/v1/projects/%s/environments/%s", projectID, envID)) - if err != nil { - t.Fatalf("infra.SoftDeleteEnvironment: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.SoftDeleteEnvironment: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// TagSeed contains metadata for a tag created via the Node.js API. -type TagSeed struct { - ID string - Slug string - Name string -} - -// CreateTag creates a project tag via the Node.js API. -func (n *NodeJSService) CreateTag(t *testing.T, projectID, slug, name, color string) *TagSeed { - t.Helper() - - var resp CreateTagResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateTagRequest{ - Slug: slug, - Name: name, - Color: color, - }). - SetResult(&resp). - Post(fmt.Sprintf("/api/v1/projects/%s/tags", projectID)) - if err != nil { - t.Fatalf("infra.CreateTag: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateTag: returned %d: %s", r.StatusCode(), r.String()) - } - - return &TagSeed{ - ID: resp.Tag.ID, - Slug: slug, - Name: name, - } -} - -// GetIdentityAccessToken creates and returns an access token for an identity. -// This is useful for testing API calls as that specific identity. -func (n *NodeJSService) GetIdentityAccessToken(t *testing.T, identityID string) string { - t.Helper() - - // First, create a universal auth method for the identity - // This response contains the clientId - var universalAuthResp CreateUniversalAuthResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateUniversalAuthRequest{ - IdentityID: identityID, - AccessTokenTrustedIPs: []IPAddress{{IPAddress: "0.0.0.0/0"}}, - AccessTokenTTL: 3600, - AccessTokenMaxTTL: 7200, - AccessTokenNumUsesLimit: 0, - ClientSecretTrustedIPs: []IPAddress{{IPAddress: "0.0.0.0/0"}}, - ClientSecretNumUsesLimit: 0, - IsClientSecretRotationEnabled: false, - }). - SetResult(&universalAuthResp). - Post("/api/v1/auth/universal-auth/identities/" + identityID) - if err != nil { - t.Fatalf("infra.GetIdentityAccessToken: create auth failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.GetIdentityAccessToken: create auth returned %d: %s", r.StatusCode(), r.String()) - } - - clientID := universalAuthResp.IdentityUniversalAuth.ClientID - - // Then create a client secret - var clientSecretResp CreateClientSecretResponse - r, err = n.client.R(). - SetAuthToken(n.identityToken). - SetBody(CreateClientSecretRequest{ - Description: "test-client-secret", - TTL: 0, - NumUsesLimit: 0, - }). - SetResult(&clientSecretResp). - Post("/api/v1/auth/universal-auth/identities/" + identityID + "/client-secrets") - if err != nil { - t.Fatalf("infra.GetIdentityAccessToken: create client secret failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.GetIdentityAccessToken: create client secret returned %d: %s", r.StatusCode(), r.String()) - } - - clientSecret := clientSecretResp.ClientSecret - - // Finally, login to get the access token - var loginResp UniversalAuthLoginResponse - r, err = n.client.R(). - SetBody(UniversalAuthLoginRequest{ - ClientID: clientID, - ClientSecret: clientSecret, - }). - SetResult(&loginResp). - Post("/api/v1/auth/universal-auth/login") - if err != nil { - t.Fatalf("infra.GetIdentityAccessToken: login failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.GetIdentityAccessToken: login returned %d: %s", r.StatusCode(), r.String()) - } - - return loginResp.AccessToken -} - -// RevokeAccessToken revokes an identity access token via the Node.js API. -func (n *NodeJSService) RevokeAccessToken(t *testing.T, accessToken string) { - t.Helper() - - r, err := n.client.R(). - SetBody(map[string]string{"accessToken": accessToken}). - Post("/api/v1/auth/token/revoke") - if err != nil { - t.Fatalf("infra.RevokeAccessToken: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.RevokeAccessToken: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// ServiceTokenScope defines the scope for a service token. -type ServiceTokenScope struct { - Environment string `json:"environment"` - SecretPath string `json:"secretPath"` -} - -// CreateServiceTokenRequest is the request body for creating a service token. -type CreateServiceTokenRequest struct { - Name string `json:"name"` - WorkspaceID string `json:"workspaceId"` - Scopes []ServiceTokenScope `json:"scopes"` - EncryptedKey string `json:"encryptedKey"` - IV string `json:"iv"` - Tag string `json:"tag"` - ExpiresIn *int `json:"expiresIn"` - Permissions []string `json:"permissions"` -} - -// CreateServiceTokenResponse is the response from creating a service token. -type CreateServiceTokenResponse struct { - ServiceToken string `json:"serviceToken"` - ServiceTokenData struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"serviceTokenData"` -} - -// ServiceTokenSeed holds the created service token info. -type ServiceTokenSeed struct { - ID string - Name string - Token string -} - -// CreateServiceTokenOpts contains options for creating a service token. -type CreateServiceTokenOpts struct { - Scopes []ServiceTokenScope - Permissions []string - ExpiresIn *int -} - -// CreateServiceToken creates a service token for a project. -func (n *NodeJSService) CreateServiceToken(t *testing.T, projectID string, opts *CreateServiceTokenOpts) *ServiceTokenSeed { - t.Helper() - - scopes := []ServiceTokenScope{{Environment: "dev", SecretPath: "/"}} - permissions := []string{"read", "write"} - - if opts != nil { - if len(opts.Scopes) > 0 { - scopes = opts.Scopes - } - if len(opts.Permissions) > 0 { - permissions = opts.Permissions - } - } - - var expiresIn *int - if opts != nil { - expiresIn = opts.ExpiresIn - } - - var resp CreateServiceTokenResponse - r, err := n.client.R(). - SetAuthToken(n.userToken). - SetBody(CreateServiceTokenRequest{ - Name: "test-service-token-" + uuid.New().String()[:8], - WorkspaceID: projectID, - Scopes: scopes, - EncryptedKey: "", - IV: "", - Tag: "", - ExpiresIn: expiresIn, - Permissions: permissions, - }). - SetResult(&resp). - Post("/api/v2/service-token") - if err != nil { - t.Fatalf("infra.CreateServiceToken: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.CreateServiceToken: returned %d: %s", r.StatusCode(), r.String()) - } - - return &ServiceTokenSeed{ - ID: resp.ServiceTokenData.ID, - Name: resp.ServiceTokenData.Name, - Token: resp.ServiceToken, - } -} - -// DeleteServiceToken deletes a service token. -func (n *NodeJSService) DeleteServiceToken(t *testing.T, serviceTokenID string) { - t.Helper() - - r, err := n.client.R(). - SetAuthToken(n.userToken). - Delete("/api/v2/service-token/" + serviceTokenID) - if err != nil { - t.Fatalf("infra.DeleteServiceToken: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.DeleteServiceToken: returned %d: %s", r.StatusCode(), r.String()) - } -} - -// GetSecretByKey reads a secret by key via the Node.js API. -func (n *NodeJSService) GetSecretByKey(t *testing.T, projectID, environment, secretPath, key string) *SecretSeed { - t.Helper() - - var resp GetSecretResponse - r, err := n.client.R(). - SetAuthToken(n.identityToken). - SetQueryParams(map[string]string{ - "projectId": projectID, - "environment": environment, - "secretPath": secretPath, - }). - SetResult(&resp). - Get(fmt.Sprintf("/api/v4/secrets/%s", key)) - if err != nil { - t.Fatalf("infra.GetSecretByKey: request failed: %v", err) - } - if r.IsError() { - t.Fatalf("infra.GetSecretByKey: returned %d: %s", r.StatusCode(), r.String()) - } - - return &SecretSeed{ - ID: resp.Secret.ID, - Key: resp.Secret.Key, - Value: resp.Secret.Value, - Version: resp.Secret.Version, - } -} diff --git a/backend-go/tests/infra/nodejs/access_tokens.go b/backend-go/tests/infra/nodejs/access_tokens.go new file mode 100644 index 00000000000..e4eadf4358e --- /dev/null +++ b/backend-go/tests/infra/nodejs/access_tokens.go @@ -0,0 +1,113 @@ +//go:build integration + +package nodejs + +// IPAddress represents a trusted IP address. +type IPAddress struct { + IPAddress string `json:"ipAddress"` +} + +// CreateUniversalAuthRequest is the request body for POST /api/v1/auth/universal-auth/identities/{id}. +type CreateUniversalAuthRequest struct { + IdentityID string `json:"identityId"` + AccessTokenTrustedIPs []IPAddress `json:"accessTokenTrustedIps"` + AccessTokenTTL int `json:"accessTokenTTL"` + AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` + AccessTokenNumUsesLimit int `json:"accessTokenNumUsesLimit"` + ClientSecretTrustedIPs []IPAddress `json:"clientSecretTrustedIps"` + ClientSecretNumUsesLimit int `json:"clientSecretNumUsesLimit"` + IsClientSecretRotationEnabled bool `json:"isClientSecretRotationEnabled"` +} + +// CreateUniversalAuthResponse is the response from POST /api/v1/auth/universal-auth/identities/{id}. +type CreateUniversalAuthResponse struct { + IdentityUniversalAuth struct { + ID string `json:"id"` + ClientID string `json:"clientId"` + } `json:"identityUniversalAuth"` +} + +// CreateClientSecretRequest is the request body for POST /api/v1/auth/universal-auth/identities/{id}/client-secrets. +type CreateClientSecretRequest struct { + Description string `json:"description"` + TTL int `json:"ttl"` + NumUsesLimit int `json:"numUsesLimit"` +} + +// CreateClientSecretResponse is the response from POST /api/v1/auth/universal-auth/identities/{id}/client-secrets. +// Note: clientId is NOT in this response - it's in the universal auth creation response. +type CreateClientSecretResponse struct { + ClientSecretData struct { + ID string `json:"id"` + ClientSecretPrefix string `json:"clientSecretPrefix"` + } `json:"clientSecretData"` + ClientSecret string `json:"clientSecret"` +} + +// UniversalAuthLoginRequest is the request body for POST /api/v1/auth/universal-auth/login. +type UniversalAuthLoginRequest struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +// UniversalAuthLoginResponse is the response from POST /api/v1/auth/universal-auth/login. +type UniversalAuthLoginResponse struct { + AccessToken string `json:"accessToken"` +} + +// AccessTokensAPI groups identity access-token endpoints. +type AccessTokensAPI struct{ apiBase } + +// ForIdentity provisions universal-auth on the identity and returns a fresh +// access token, useful for exercising API calls as that identity. +func (a AccessTokensAPI) ForIdentity(identityID string) string { + a.t.Helper() + + // 1. Attach a universal auth method (returns the clientId). + var universalAuthResp CreateUniversalAuthResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateUniversalAuthRequest{ + IdentityID: identityID, + AccessTokenTrustedIPs: []IPAddress{{IPAddress: "0.0.0.0/0"}}, + AccessTokenTTL: 3600, + AccessTokenMaxTTL: 7200, + AccessTokenNumUsesLimit: 0, + ClientSecretTrustedIPs: []IPAddress{{IPAddress: "0.0.0.0/0"}}, + ClientSecretNumUsesLimit: 0, + IsClientSecretRotationEnabled: false, + }). + SetResult(&universalAuthResp). + Post("/api/v1/auth/universal-auth/identities/" + identityID) + a.check("AccessTokens.ForIdentity(universal-auth)", r, err) + + clientID := universalAuthResp.IdentityUniversalAuth.ClientID + + // 2. Mint a client secret. + var clientSecretResp CreateClientSecretResponse + r, err = a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateClientSecretRequest{Description: "test-client-secret", TTL: 0, NumUsesLimit: 0}). + SetResult(&clientSecretResp). + Post("/api/v1/auth/universal-auth/identities/" + identityID + "/client-secrets") + a.check("AccessTokens.ForIdentity(client-secret)", r, err) + + // 3. Log in to obtain the access token. + var loginResp UniversalAuthLoginResponse + r, err = a.svc.client.R(). + SetBody(UniversalAuthLoginRequest{ClientID: clientID, ClientSecret: clientSecretResp.ClientSecret}). + SetResult(&loginResp). + Post("/api/v1/auth/universal-auth/login") + a.check("AccessTokens.ForIdentity(login)", r, err) + + return loginResp.AccessToken +} + +// Revoke revokes an identity access token. +func (a AccessTokensAPI) Revoke(accessToken string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetBody(map[string]string{"accessToken": accessToken}). + Post("/api/v1/auth/token/revoke") + a.check("AccessTokens.Revoke", r, err) +} diff --git a/backend-go/tests/infra/nodejs/environments.go b/backend-go/tests/infra/nodejs/environments.go new file mode 100644 index 00000000000..61801220c56 --- /dev/null +++ b/backend-go/tests/infra/nodejs/environments.go @@ -0,0 +1,53 @@ +//go:build integration + +package nodejs + +import "fmt" + +// CreateEnvironmentRequest is the request body for POST /api/v1/projects/{id}/environments. +type CreateEnvironmentRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// CreateEnvironmentResponse is the response from POST /api/v1/projects/{id}/environments. +type CreateEnvironmentResponse struct { + Environment struct { + ID string `json:"id"` + } `json:"environment"` +} + +// EnvironmentSeed contains metadata for an environment created via the Node.js API. +type EnvironmentSeed struct { + ID string + Slug string + Name string +} + +// EnvironmentsAPI groups project environment endpoints. +type EnvironmentsAPI struct{ apiBase } + +// Create adds an environment to a project. +func (a EnvironmentsAPI) Create(projectID, slug, name string) *EnvironmentSeed { + a.t.Helper() + + var resp CreateEnvironmentResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateEnvironmentRequest{Slug: slug, Name: name}). + SetResult(&resp). + Post(fmt.Sprintf("/api/v1/projects/%s/environments", projectID)) + a.check("Environments.Create", r, err) + + return &EnvironmentSeed{ID: resp.Environment.ID, Slug: slug, Name: name} +} + +// SoftDelete marks an environment for deletion (recoverable) rather than removing +// it immediately. +func (a EnvironmentsAPI) SoftDelete(projectID, envID string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + Delete(fmt.Sprintf("/api/v1/projects/%s/environments/%s", projectID, envID)) + a.check("Environments.SoftDelete", r, err) +} diff --git a/backend-go/tests/infra/nodejs/facade.go b/backend-go/tests/infra/nodejs/facade.go new file mode 100644 index 00000000000..504d47296e5 --- /dev/null +++ b/backend-go/tests/infra/nodejs/facade.go @@ -0,0 +1,64 @@ +//go:build integration + +package nodejs + +import ( + "testing" + + "github.com/go-resty/resty/v2" +) + +// API is a per-test facade over the seed helpers, grouped by domain. Obtain it +// with svc.For(t); the bound *testing.T is used for t.Helper and fatal-on-error. +type API struct { + Projects ProjectsAPI + Identities IdentitiesAPI + Users UsersAPI + Groups GroupsAPI + Roles RolesAPI + Secrets SecretsAPI + Folders FoldersAPI + Imports ImportsAPI + Environments EnvironmentsAPI + Tags TagsAPI + ServiceTokens ServiceTokensAPI + AccessTokens AccessTokensAPI +} + +// For binds the service to a test and returns the domain facade. +func (s *Service) For(t *testing.T) *API { + t.Helper() + b := apiBase{svc: s, t: t} + return &API{ + Projects: ProjectsAPI{b}, + Identities: IdentitiesAPI{b}, + Users: UsersAPI{b}, + Groups: GroupsAPI{b}, + Roles: RolesAPI{b}, + Secrets: SecretsAPI{b}, + Folders: FoldersAPI{b}, + Imports: ImportsAPI{b}, + Environments: EnvironmentsAPI{b}, + Tags: TagsAPI{b}, + ServiceTokens: ServiceTokensAPI{b}, + AccessTokens: AccessTokensAPI{b}, + } +} + +// apiBase carries the service handle and bound test for every domain API. +type apiBase struct { + svc *Service + t *testing.T +} + +// check aborts the test with a consistent message when a request fails or +// returns a non-2xx status. +func (b apiBase) check(op string, r *resty.Response, err error) { + b.t.Helper() + if err != nil { + b.t.Fatalf("nodejs.%s: request failed: %v", op, err) + } + if r.IsError() { + b.t.Fatalf("nodejs.%s: returned %d: %s", op, r.StatusCode(), r.String()) + } +} diff --git a/backend-go/tests/infra/nodejs/folders.go b/backend-go/tests/infra/nodejs/folders.go new file mode 100644 index 00000000000..4354d76e734 --- /dev/null +++ b/backend-go/tests/infra/nodejs/folders.go @@ -0,0 +1,42 @@ +//go:build integration + +package nodejs + +// CreateFolderRequest is the request body for POST /api/v2/folders. +type CreateFolderRequest struct { + ProjectID string `json:"projectId"` + Environment string `json:"environment"` + Path string `json:"path"` + Name string `json:"name"` +} + +// CreateFolderResponse is the response from POST /api/v2/folders. +type CreateFolderResponse struct { + Folder struct { + ID string `json:"id"` + } `json:"folder"` +} + +// FolderSeed contains metadata for a folder created via the Node.js API. +type FolderSeed struct { + ID string + Name string +} + +// FoldersAPI groups folder endpoints. +type FoldersAPI struct{ apiBase } + +// Create makes a folder named name under parentPath. +func (a FoldersAPI) Create(projectID, environment, parentPath, name string) *FolderSeed { + a.t.Helper() + + var resp CreateFolderResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateFolderRequest{ProjectID: projectID, Environment: environment, Path: parentPath, Name: name}). + SetResult(&resp). + Post("/api/v2/folders") + a.check("Folders.Create", r, err) + + return &FolderSeed{ID: resp.Folder.ID, Name: name} +} diff --git a/backend-go/tests/infra/nodejs/groups.go b/backend-go/tests/infra/nodejs/groups.go new file mode 100644 index 00000000000..c671e7c9d44 --- /dev/null +++ b/backend-go/tests/infra/nodejs/groups.go @@ -0,0 +1,70 @@ +//go:build integration + +package nodejs + +import "fmt" + +// CreateGroupRequest is the request body for POST /api/v1/groups. +type CreateGroupRequest struct { + Name string `json:"name"` + Role string `json:"role"` +} + +// CreateGroupResponse is the response from POST /api/v1/groups. +type CreateGroupResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// AddGroupToProjectRequest is the request body for POST /api/v1/projects/{id}/memberships/groups/{id}. +type AddGroupToProjectRequest struct { + Role string `json:"role"` +} + +// GroupSeed contains metadata for an org group created via the Node.js API. +type GroupSeed struct { + ID string + Name string + Slug string +} + +// GroupsAPI groups org-group endpoints. Requires the "groups" EE feature. +type GroupsAPI struct{ apiBase } + +// Create makes a no-access org group. +func (a GroupsAPI) Create(name string) *GroupSeed { + a.t.Helper() + + var resp CreateGroupResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateGroupRequest{Name: name, Role: "no-access"}). + SetResult(&resp). + Post("/api/v1/groups") + a.check("Groups.Create", r, err) + + return &GroupSeed{ID: resp.ID, Name: resp.Name, Slug: resp.Slug} +} + +// AddUser adds a user (by username/email) to a group. Uses the admin user JWT +// because group member management runs privilege-boundary checks. +func (a GroupsAPI) AddUser(groupID, username string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetAuthToken(a.svc.userToken). + SetHeader("Content-Type", "application/json"). + SetBody(struct{}{}). + Post(fmt.Sprintf("/api/v1/groups/%s/users/%s", groupID, username)) + a.check("Groups.AddUser", r, err) +} + +// AddToProject adds a group to a project with the given role. +func (a GroupsAPI) AddToProject(projectID, groupID, role string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(AddGroupToProjectRequest{Role: role}). + Post(fmt.Sprintf("/api/v1/projects/%s/memberships/groups/%s", projectID, groupID)) + a.check("Groups.AddToProject", r, err) +} diff --git a/backend-go/tests/infra/nodejs/identities.go b/backend-go/tests/infra/nodejs/identities.go new file mode 100644 index 00000000000..f133b9076dd --- /dev/null +++ b/backend-go/tests/infra/nodejs/identities.go @@ -0,0 +1,156 @@ +//go:build integration + +package nodejs + +// CreateIdentityRequest is the request body for POST /api/v1/identities. +type CreateIdentityRequest struct { + Name string `json:"name"` + OrganizationID string `json:"organizationId"` + Role string `json:"role"` +} + +// CreateIdentityResponse is the response from POST /api/v1/identities. +type CreateIdentityResponse struct { + Identity struct { + ID string `json:"id"` + } `json:"identity"` +} + +// IdentitySeed contains IDs for a machine identity created via the Node.js API. +type IdentitySeed struct { + ID string + Name string +} + +// RoleAssignment represents a project role with optional temporary access. +type RoleAssignment struct { + Role string `json:"role"` + IsTemporary bool `json:"isTemporary,omitempty"` + TemporaryMode string `json:"temporaryMode,omitempty"` + TemporaryRange string `json:"temporaryRange,omitempty"` + TemporaryAccessStartTime string `json:"temporaryAccessStartTime,omitempty"` +} + +// AddIdentityToProjectWithRolesRequest is the request body with a roles array. +type AddIdentityToProjectWithRolesRequest struct { + Roles []RoleAssignment `json:"roles"` +} + +// PrivilegeType specifies whether an additional privilege is temporary. Shared by +// identity and user privilege requests. +type PrivilegeType struct { + IsTemporary bool `json:"isTemporary"` + TemporaryMode string `json:"temporaryMode,omitempty"` + TemporaryRange string `json:"temporaryRange,omitempty"` + TemporaryAccessStartTime string `json:"temporaryAccessStartTime,omitempty"` +} + +// CreateIdentityPrivilegeRequest is the request body for POST /api/v2/identity-project-additional-privilege. +type CreateIdentityPrivilegeRequest struct { + IdentityID string `json:"identityId"` + ProjectID string `json:"projectId"` + Permissions []Permission `json:"permissions"` + Type PrivilegeType `json:"type"` +} + +// IdentitiesAPI groups machine-identity endpoints. +type IdentitiesAPI struct{ apiBase } + +// Create makes a no-access machine identity in the bootstrap org. +func (a IdentitiesAPI) Create(name string) *IdentitySeed { + a.t.Helper() + + var resp CreateIdentityResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateIdentityRequest{Name: name, OrganizationID: a.svc.orgID, Role: "no-access"}). + SetResult(&resp). + Post("/api/v1/identities") + a.check("Identities.Create", r, err) + + return &IdentitySeed{ID: resp.Identity.ID, Name: name} +} + +// Delete removes a machine identity, revoking all its tokens. +func (a IdentitiesAPI) Delete(identityID string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + Delete("/api/v1/identities/" + identityID) + a.check("Identities.Delete", r, err) +} + +// AddToProject starts an identity-to-project membership build. Set at least one +// role via Role (simple) or Roles (explicit, e.g. temporary grants). +func (a IdentitiesAPI) AddToProject(projectID, identityID string) *addIdentityBuilder { + return &addIdentityBuilder{a: a, projectID: projectID, identityID: identityID} +} + +type addIdentityBuilder struct { + a IdentitiesAPI + projectID string + identityID string + roles []RoleAssignment +} + +// Role appends a simple permanent role by slug. +func (b *addIdentityBuilder) Role(slug string) *addIdentityBuilder { + b.roles = append(b.roles, RoleAssignment{Role: slug}) + return b +} + +// Roles appends explicit role assignments (e.g. temporary access). +func (b *addIdentityBuilder) Roles(roles ...RoleAssignment) *addIdentityBuilder { + b.roles = append(b.roles, roles...) + return b +} + +// Do assigns the configured roles. +func (b *addIdentityBuilder) Do() { + b.a.t.Helper() + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(AddIdentityToProjectWithRolesRequest{Roles: b.roles}). + Post("/api/v1/projects/" + b.projectID + "/memberships/identities/" + b.identityID) + b.a.check("Identities.AddToProject", r, err) +} + +// AdditionalPrivilege starts an additional-privilege build for an identity. +// Call Temporary for a time-bound grant; otherwise it is permanent. +func (a IdentitiesAPI) AdditionalPrivilege(identityID, projectID string, permissions ...Permission) *identityPrivilegeBuilder { + return &identityPrivilegeBuilder{a: a, identityID: identityID, projectID: projectID, permissions: permissions} +} + +type identityPrivilegeBuilder struct { + a IdentitiesAPI + identityID string + projectID string + permissions []Permission + privType PrivilegeType +} + +// Temporary makes the privilege time-bound (relative mode). +func (b *identityPrivilegeBuilder) Temporary(temporaryRange, accessStartTime string) *identityPrivilegeBuilder { + b.privType = PrivilegeType{ + IsTemporary: true, + TemporaryMode: "relative", + TemporaryRange: temporaryRange, + TemporaryAccessStartTime: accessStartTime, + } + return b +} + +// Do creates the additional privilege. +func (b *identityPrivilegeBuilder) Do() { + b.a.t.Helper() + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(CreateIdentityPrivilegeRequest{ + IdentityID: b.identityID, + ProjectID: b.projectID, + Permissions: b.permissions, + Type: b.privType, + }). + Post("/api/v2/identity-project-additional-privilege/") + b.a.check("Identities.AdditionalPrivilege", r, err) +} diff --git a/backend-go/tests/infra/nodejs/projects.go b/backend-go/tests/infra/nodejs/projects.go new file mode 100644 index 00000000000..9b9ea24db0f --- /dev/null +++ b/backend-go/tests/infra/nodejs/projects.go @@ -0,0 +1,95 @@ +//go:build integration + +package nodejs + +import ( + "crypto/rand" + "fmt" +) + +// CreateProjectRequest is the request body for POST /api/v1/projects. +type CreateProjectRequest struct { + ProjectName string `json:"projectName"` + Slug string `json:"slug"` + Type string `json:"type"` +} + +// CreateProjectResponse is the response from POST /api/v1/projects. +type CreateProjectResponse struct { + Project struct { + ID string `json:"id"` + Slug string `json:"slug"` + } `json:"project"` +} + +// ProjectSeed contains IDs for a project created via the Node.js API. +type ProjectSeed struct { + ID string + Slug string + EnvSlug string +} + +// ProjectsAPI groups project endpoints. +type ProjectsAPI struct{ apiBase } + +// Create starts a secret-manager project build. The slug is random unless set. +// On Do the creating identity becomes admin and the bootstrap user is added as +// admin too, so either can be used for subsequent API calls. +func (a ProjectsAPI) Create(name string) *createProjectBuilder { + return &createProjectBuilder{a: a, name: name} +} + +type createProjectBuilder struct { + a ProjectsAPI + name string + slug string +} + +// Slug overrides the auto-generated project slug. +func (b *createProjectBuilder) Slug(slug string) *createProjectBuilder { + b.slug = slug + return b +} + +// Do creates the project and returns its seed. +func (b *createProjectBuilder) Do() *ProjectSeed { + b.a.t.Helper() + + slug := b.slug + if slug == "" { + raw := make([]byte, 4) + rand.Read(raw) + slug = fmt.Sprintf("t-%s-%x", b.name, raw) + if len(slug) > 36 { + slug = slug[:36] + } + } + + var resp CreateProjectResponse + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(CreateProjectRequest{ProjectName: b.name, Slug: slug, Type: "secret-manager"}). + SetResult(&resp). + Post("/api/v1/projects") + b.a.check("Projects.Create", r, err) + + projectID := resp.Project.ID + + // Add bootstrap user to project as admin (identity is already admin as creator). + r, err = b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(AddUserToProjectRequest{Usernames: []string{b.a.svc.userEmail}, RoleSlugs: []string{"admin"}}). + Post(fmt.Sprintf("/api/v1/projects/%s/memberships", projectID)) + b.a.check("Projects.Create(add user)", r, err) + + return &ProjectSeed{ID: projectID, Slug: resp.Project.Slug, EnvSlug: "dev"} +} + +// Delete removes a project. +func (a ProjectsAPI) Delete(projectID string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + Delete("/api/v1/projects/" + projectID) + a.check("Projects.Delete", r, err) +} diff --git a/backend-go/tests/infra/nodejs/roles.go b/backend-go/tests/infra/nodejs/roles.go new file mode 100644 index 00000000000..525e79ebda9 --- /dev/null +++ b/backend-go/tests/infra/nodejs/roles.go @@ -0,0 +1,55 @@ +//go:build integration + +package nodejs + +import "fmt" + +// Permission represents a CASL permission rule. Shared by custom roles and +// additional privileges. +type Permission struct { + Subject string `json:"subject"` + Action any `json:"action"` + Conditions map[string]any `json:"conditions,omitempty"` + Inverted bool `json:"inverted,omitempty"` +} + +// CreateCustomRoleRequest is the request body for POST /api/v1/projects/{id}/roles. +type CreateCustomRoleRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Permissions []Permission `json:"permissions"` +} + +// CreateCustomRoleResponse is the response from POST /api/v1/projects/{id}/roles. +type CreateCustomRoleResponse struct { + Role struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"role"` +} + +// CustomRoleSeed contains metadata for a custom project role. +type CustomRoleSeed struct { + ID string + Slug string + Name string +} + +// RolesAPI groups custom project role endpoints. Requires the "rbac" EE feature. +type RolesAPI struct{ apiBase } + +// CreateCustom creates a custom project role with the given permissions. +func (a RolesAPI) CreateCustom(projectID, slug, name string, permissions ...Permission) *CustomRoleSeed { + a.t.Helper() + + var resp CreateCustomRoleResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateCustomRoleRequest{Slug: slug, Name: name, Permissions: permissions}). + SetResult(&resp). + Post(fmt.Sprintf("/api/v1/projects/%s/roles", projectID)) + a.check("Roles.CreateCustom", r, err) + + return &CustomRoleSeed{ID: resp.Role.ID, Slug: resp.Role.Slug, Name: resp.Role.Name} +} diff --git a/backend-go/tests/infra/nodejs/secret_imports.go b/backend-go/tests/infra/nodejs/secret_imports.go new file mode 100644 index 00000000000..8a783246af2 --- /dev/null +++ b/backend-go/tests/infra/nodejs/secret_imports.go @@ -0,0 +1,75 @@ +//go:build integration + +package nodejs + +// SecretImportTarget specifies the source for a secret import. +type SecretImportTarget struct { + Environment string `json:"environment"` + Path string `json:"path"` +} + +// CreateSecretImportRequest is the request body for POST /api/v2/secret-imports. +type CreateSecretImportRequest struct { + ProjectID string `json:"projectId"` + Environment string `json:"environment"` + Path string `json:"path"` + Import SecretImportTarget `json:"import"` + IsReplication bool `json:"isReplication,omitempty"` +} + +// CreateSecretImportResponse is the response from POST /api/v2/secret-imports. +type CreateSecretImportResponse struct { + SecretImport struct { + ID string `json:"id"` + } `json:"secretImport"` +} + +// SecretImportSeed contains metadata for a secret import created via the Node.js API. +type SecretImportSeed struct { + ID string +} + +// ImportsAPI groups secret-import endpoints. +type ImportsAPI struct{ apiBase } + +// Create starts an import of secrets from (importEnv, importPath) into +// (environment, path). Call Replication for a replicated import (requires the +// secretApproval EE feature; secrets are copied asynchronously into a reserved +// folder). +func (a ImportsAPI) Create(projectID, environment, path, importEnv, importPath string) *createImportBuilder { + return &createImportBuilder{ + a: a, + req: CreateSecretImportRequest{ + ProjectID: projectID, + Environment: environment, + Path: path, + Import: SecretImportTarget{Environment: importEnv, Path: importPath}, + }, + } +} + +type createImportBuilder struct { + a ImportsAPI + req CreateSecretImportRequest +} + +// Replication marks the import as a replication import. +func (b *createImportBuilder) Replication() *createImportBuilder { + b.req.IsReplication = true + return b +} + +// Do creates the import and returns its seed. +func (b *createImportBuilder) Do() *SecretImportSeed { + b.a.t.Helper() + + var resp CreateSecretImportResponse + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(b.req). + SetResult(&resp). + Post("/api/v2/secret-imports") + b.a.check("Imports.Create", r, err) + + return &SecretImportSeed{ID: resp.SecretImport.ID} +} diff --git a/backend-go/tests/infra/nodejs/secrets.go b/backend-go/tests/infra/nodejs/secrets.go new file mode 100644 index 00000000000..638c5ab6c28 --- /dev/null +++ b/backend-go/tests/infra/nodejs/secrets.go @@ -0,0 +1,228 @@ +//go:build integration + +package nodejs + +// SecretMetadataEntry represents a metadata key-value pair for a secret. +type SecretMetadataEntry struct { + Key string `json:"key"` + Value string `json:"value"` + IsEncrypted bool `json:"isEncrypted,omitempty"` +} + +// CreateSecretRequest is the request body for POST /api/v4/secrets/{key}. +type CreateSecretRequest struct { + ProjectID string `json:"projectId"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` + SecretValue string `json:"secretValue"` + SecretComment string `json:"secretComment,omitempty"` + SecretMetadata []SecretMetadataEntry `json:"secretMetadata,omitempty"` + Type string `json:"type"` + TagIDs []string `json:"tagIds,omitempty"` + SecretReminderNote string `json:"secretReminderNote,omitempty"` + SecretReminderRepeatDays *int `json:"secretReminderRepeatDays,omitempty"` + SkipMultilineEncoding bool `json:"skipMultilineEncoding,omitempty"` +} + +// CreateSecretResponse is the response from POST /api/v4/secrets/{key}. +type CreateSecretResponse struct { + Secret struct { + ID string `json:"id"` + } `json:"secret"` +} + +// UpdateSecretRequest is the request body for PATCH /api/v4/secrets/{key}. +type UpdateSecretRequest struct { + ProjectID string `json:"projectId"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` + SecretValue string `json:"secretValue"` +} + +// UpdateSecretResponse is the response from PATCH /api/v4/secrets/{key}. +type UpdateSecretResponse struct { + Secret struct { + ID string `json:"id"` + Version int `json:"version"` + } `json:"secret"` +} + +// GetSecretResponse is the response from GET /api/v4/secrets/{key}. +type GetSecretResponse struct { + Secret struct { + ID string `json:"id"` + Key string `json:"secretKey"` + Value string `json:"secretValue"` + Version int `json:"version"` + SecretPath string `json:"secretPath"` + Environment string `json:"environment"` + } `json:"secret"` +} + +// SecretSeed contains metadata for a secret created via the Node.js API. +type SecretSeed struct { + ID string + Key string + Value string + Version int +} + +// SecretsAPI groups secret endpoints. +type SecretsAPI struct{ apiBase } + +// Create starts a secret build. Path defaults to "/" and Type to shared. +func (a SecretsAPI) Create(projectID, environment, key, value string) *createSecretBuilder { + return &createSecretBuilder{ + a: a, + key: key, + req: CreateSecretRequest{ + ProjectID: projectID, + Environment: environment, + SecretPath: "/", + SecretValue: value, + Type: "shared", + }, + } +} + +type createSecretBuilder struct { + a SecretsAPI + key string + req CreateSecretRequest +} + +// Path sets the secret folder path (default "/"). +func (b *createSecretBuilder) Path(path string) *createSecretBuilder { + b.req.SecretPath = path + return b +} + +// Comment sets the secret comment. +func (b *createSecretBuilder) Comment(comment string) *createSecretBuilder { + b.req.SecretComment = comment + return b +} + +// Metadata appends metadata entries. +func (b *createSecretBuilder) Metadata(entries ...SecretMetadataEntry) *createSecretBuilder { + b.req.SecretMetadata = append(b.req.SecretMetadata, entries...) + return b +} + +// Tags appends tag IDs. +func (b *createSecretBuilder) Tags(tagIDs ...string) *createSecretBuilder { + b.req.TagIDs = append(b.req.TagIDs, tagIDs...) + return b +} + +// Personal marks the secret as a personal override (sent with the user token). +func (b *createSecretBuilder) Personal() *createSecretBuilder { + b.req.Type = "personal" + return b +} + +// Reminder sets the reminder note and repeat interval. +func (b *createSecretBuilder) Reminder(note string, repeatDays int) *createSecretBuilder { + b.req.SecretReminderNote = note + b.req.SecretReminderRepeatDays = &repeatDays + return b +} + +// SkipMultilineEncoding sets the skipMultilineEncoding flag. +func (b *createSecretBuilder) SkipMultilineEncoding() *createSecretBuilder { + b.req.SkipMultilineEncoding = true + return b +} + +// Do creates the secret and returns its seed. +func (b *createSecretBuilder) Do() *SecretSeed { + b.a.t.Helper() + + token := b.a.svc.identityToken + if b.req.Type == "personal" { + token = b.a.svc.userToken + } + + var resp CreateSecretResponse + r, err := b.a.svc.client.R(). + SetAuthToken(token). + SetBody(b.req). + SetResult(&resp). + Post("/api/v4/secrets/" + b.key) + b.a.check("Secrets.Create", r, err) + + return &SecretSeed{ID: resp.Secret.ID, Key: b.key, Value: b.req.SecretValue, Version: 1} +} + +// Update starts a secret value update. Path defaults to "/". +func (a SecretsAPI) Update(projectID, environment, key, newValue string) *updateSecretBuilder { + return &updateSecretBuilder{ + a: a, + key: key, + req: UpdateSecretRequest{ + ProjectID: projectID, + Environment: environment, + SecretPath: "/", + SecretValue: newValue, + }, + } +} + +type updateSecretBuilder struct { + a SecretsAPI + key string + req UpdateSecretRequest +} + +// Path sets the secret folder path (default "/"). +func (b *updateSecretBuilder) Path(path string) *updateSecretBuilder { + b.req.SecretPath = path + return b +} + +// Do updates the secret value. +func (b *updateSecretBuilder) Do() { + b.a.t.Helper() + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(b.req). + Patch("/api/v4/secrets/" + b.key) + b.a.check("Secrets.Update", r, err) +} + +// Get starts a secret read. Path defaults to "/". +func (a SecretsAPI) Get(projectID, environment, key string) *getSecretBuilder { + return &getSecretBuilder{a: a, projectID: projectID, environment: environment, key: key, path: "/"} +} + +type getSecretBuilder struct { + a SecretsAPI + projectID string + environment string + key string + path string +} + +// Path sets the secret folder path (default "/"). +func (b *getSecretBuilder) Path(path string) *getSecretBuilder { + b.path = path + return b +} + +// Do reads the secret and returns its seed. +func (b *getSecretBuilder) Do() *SecretSeed { + b.a.t.Helper() + var resp GetSecretResponse + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetQueryParams(map[string]string{ + "projectId": b.projectID, + "environment": b.environment, + "secretPath": b.path, + }). + SetResult(&resp). + Get("/api/v4/secrets/" + b.key) + b.a.check("Secrets.Get", r, err) + + return &SecretSeed{ID: resp.Secret.ID, Key: resp.Secret.Key, Value: resp.Secret.Value, Version: resp.Secret.Version} +} diff --git a/backend-go/tests/infra/nodejs/service.go b/backend-go/tests/infra/nodejs/service.go new file mode 100644 index 00000000000..0d001a8a0e9 --- /dev/null +++ b/backend-go/tests/infra/nodejs/service.go @@ -0,0 +1,230 @@ +//go:build integration + +package nodejs + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/go-resty/resty/v2" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/infisical/api/internal/database/pg" +) + +const ( + image = "infisical/infisical:latest" + networkAlias = "backend-nodejs" + redisURL = "redis://redis:6379" + nodeEnv = "development" +) + +// Config carries everything the Node.js container needs to start. The infra +// builder fills it from its own constants so this package never imports infra +// (keeping the infra -> nodejs dependency acyclic). +type Config struct { + NetworkName string + Files []testcontainers.ContainerFile + Cmd []string + DBUser string + DBPassword string + DBName string + EncryptionKey string + AuthSecret string +} + +// Service provides access to a running Node.js backend container and the +// bootstrapped credentials (admin user, org, machine identity). +type Service struct { + container testcontainers.Container + url string + client *resty.Client + db pg.DB + orgID string + userID string + userEmail string + identityToken string + userToken string +} + +func (s *Service) URL() string { return s.url } +func (s *Service) OrgID() string { return s.orgID } +func (s *Service) UserID() string { return s.userID } +func (s *Service) UserEmail() string { return s.userEmail } +func (s *Service) IdentityToken() string { return s.identityToken } +func (s *Service) UserToken() string { return s.userToken } +func (s *Service) Client() *resty.Client { return s.client } + +// AttachDB gives the service a DB handle for seed helpers that read rows the API +// doesn't return (e.g. user IDs after invite). +func (s *Service) AttachDB(db pg.DB) { s.db = db } + +// Terminate stops and removes the underlying container. +func (s *Service) Terminate(ctx context.Context) error { + return s.container.Terminate(ctx) +} + +// Start launches the Node.js backend container and returns the service. Bootstrap +// must be called once the supporting infra (DB/Redis) is reachable. +func Start(ctx context.Context, cfg *Config) (*Service, error) { + // When a custom Cmd is provided (e.g. for patching files via sed), we need + // to run as root because the container image sets USER non-root-user which + // cannot write to root-owned paths like /backend/dist/. + user := "" + if len(cfg.Cmd) > 0 { + user = "root" + } + + req := testcontainers.ContainerRequest{ + Image: image, + ExposedPorts: []string{"8080/tcp"}, + Networks: []string{cfg.NetworkName}, + NetworkAliases: map[string][]string{ + cfg.NetworkName: {networkAlias}, + }, + User: user, + Env: map[string]string{ + "NODE_ENV": nodeEnv, + "DB_CONNECTION_URI": fmt.Sprintf("postgres://%s:%s@db:5432/%s?sslmode=disable", cfg.DBUser, cfg.DBPassword, cfg.DBName), + "REDIS_URL": redisURL, + "ENCRYPTION_KEY": cfg.EncryptionKey, + "AUTH_SECRET": cfg.AuthSecret, + "SITE_URL": "http://localhost:8080", + "TELEMETRY_ENABLED": "false", + "SMTP_HOST": "", + }, + Files: cfg.Files, + Cmd: cfg.Cmd, + WaitingFor: wait.ForHTTP("/api/status").WithPort("8080/tcp").WithStartupTimeout(120 * time.Second), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("starting nodejs: %w", err) + } + + host, err := container.Host(ctx) + if err != nil { + return nil, fmt.Errorf("getting nodejs host: %w", err) + } + + mappedPort, err := container.MappedPort(ctx, "8080/tcp") + if err != nil { + return nil, fmt.Errorf("getting nodejs port: %w", err) + } + + baseURL := fmt.Sprintf("http://%s:%d", host, mappedPort.Int()) + + return &Service{ + container: container, + url: baseURL, + client: resty.New().SetBaseURL(baseURL), + }, nil +} + +// BootstrapRequest is the request body for POST /api/v1/admin/bootstrap. +type BootstrapRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Organization string `json:"organization"` +} + +// BootstrapResponse is the response from POST /api/v1/admin/bootstrap. +type BootstrapResponse struct { + Organization struct { + ID string `json:"id"` + } `json:"organization"` + Identity struct { + Credentials struct { + Token string `json:"token"` + } `json:"credentials"` + } `json:"identity"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"user"` +} + +// LoginRequest is the request body for POST /api/v3/auth/login. +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// LoginResponse is the response from POST /api/v3/auth/login. +type LoginResponse struct { + AccessToken string `json:"accessToken"` +} + +// SelectOrgRequest is the request body for POST /api/v3/auth/select-organization. +type SelectOrgRequest struct { + OrganizationID string `json:"organizationId"` +} + +// SelectOrgResponse is the response from POST /api/v3/auth/select-organization. +type SelectOrgResponse struct { + Token string `json:"token"` +} + +// Bootstrap creates the initial admin user, org, and machine identity, then logs +// in to obtain an org-scoped user JWT. Uses log.Fatalf since it runs in TestMain. +func (s *Service) Bootstrap() { + var bootstrapResp BootstrapResponse + resp, err := s.client.R(). + SetBody(BootstrapRequest{ + Email: "test-admin@example.com", + Password: "testpassword123", + Organization: "test-org", + }). + SetResult(&bootstrapResp). + Post("/api/v1/admin/bootstrap") + if err != nil { + log.Fatalf("nodejs.Bootstrap: request failed: %v", err) + } + if resp.IsError() { + log.Fatalf("nodejs.Bootstrap: returned %d: %s", resp.StatusCode(), resp.String()) + } + + s.orgID = bootstrapResp.Organization.ID + s.identityToken = bootstrapResp.Identity.Credentials.Token + s.userEmail = bootstrapResp.User.Email + s.userID = bootstrapResp.User.ID + + var loginResp LoginResponse + resp, err = s.client.R(). + SetBody(LoginRequest{ + Email: s.userEmail, + Password: "testpassword123", + }). + SetResult(&loginResp). + Post("/api/v3/auth/login") + if err != nil { + log.Fatalf("nodejs.Bootstrap: login request failed: %v", err) + } + if resp.IsError() { + log.Fatalf("nodejs.Bootstrap: login returned %d: %s", resp.StatusCode(), resp.String()) + } + + // Select organization to get an org-scoped JWT (required for org-level API calls). + var selectOrgResp SelectOrgResponse + resp, err = s.client.R(). + SetHeader("Authorization", "Bearer "+loginResp.AccessToken). + SetBody(SelectOrgRequest{ + OrganizationID: s.orgID, + }). + SetResult(&selectOrgResp). + Post("/api/v3/auth/select-organization") + if err != nil { + log.Fatalf("nodejs.Bootstrap: select-org request failed: %v", err) + } + if resp.IsError() { + log.Fatalf("nodejs.Bootstrap: select-org returned %d: %s", resp.StatusCode(), resp.String()) + } + s.userToken = selectOrgResp.Token +} diff --git a/backend-go/tests/infra/nodejs/service_tokens.go b/backend-go/tests/infra/nodejs/service_tokens.go new file mode 100644 index 00000000000..8f2f5a450f2 --- /dev/null +++ b/backend-go/tests/infra/nodejs/service_tokens.go @@ -0,0 +1,112 @@ +//go:build integration + +package nodejs + +import "github.com/google/uuid" + +// ServiceTokenScope defines the environment/path scope for a service token. +type ServiceTokenScope struct { + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` +} + +// CreateServiceTokenRequest is the request body for POST /api/v2/service-token. +type CreateServiceTokenRequest struct { + Name string `json:"name"` + WorkspaceID string `json:"workspaceId"` + Scopes []ServiceTokenScope `json:"scopes"` + EncryptedKey string `json:"encryptedKey"` + IV string `json:"iv"` + Tag string `json:"tag"` + ExpiresIn *int `json:"expiresIn"` + Permissions []string `json:"permissions"` +} + +// CreateServiceTokenResponse is the response from POST /api/v2/service-token. +type CreateServiceTokenResponse struct { + ServiceToken string `json:"serviceToken"` + ServiceTokenData struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"serviceTokenData"` +} + +// ServiceTokenSeed holds the created service token info. +type ServiceTokenSeed struct { + ID string + Name string + Token string +} + +// ServiceTokensAPI groups service-token endpoints. +type ServiceTokensAPI struct{ apiBase } + +// Create starts a service-token build. By default it is scoped to dev:/ with +// read+write permissions; override with Scopes/Permissions. +func (a ServiceTokensAPI) Create(projectID string) *createServiceTokenBuilder { + return &createServiceTokenBuilder{ + a: a, + projectID: projectID, + scopes: []ServiceTokenScope{{Environment: "dev", SecretPath: "/"}}, + permissions: []string{"read", "write"}, + } +} + +type createServiceTokenBuilder struct { + a ServiceTokensAPI + projectID string + scopes []ServiceTokenScope + permissions []string + expiresIn *int +} + +// Scopes replaces the token's env/path scopes. +func (b *createServiceTokenBuilder) Scopes(scopes ...ServiceTokenScope) *createServiceTokenBuilder { + b.scopes = scopes + return b +} + +// Permissions replaces the token's permissions (e.g. "read", "write"). +func (b *createServiceTokenBuilder) Permissions(permissions ...string) *createServiceTokenBuilder { + b.permissions = permissions + return b +} + +// ExpiresIn sets the token expiry in seconds. +func (b *createServiceTokenBuilder) ExpiresIn(seconds int) *createServiceTokenBuilder { + b.expiresIn = &seconds + return b +} + +// Do creates the service token and returns its seed. +func (b *createServiceTokenBuilder) Do() *ServiceTokenSeed { + b.a.t.Helper() + + var resp CreateServiceTokenResponse + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.userToken). + SetBody(CreateServiceTokenRequest{ + Name: "test-service-token-" + uuid.New().String()[:8], + WorkspaceID: b.projectID, + Scopes: b.scopes, + EncryptedKey: "", + IV: "", + Tag: "", + ExpiresIn: b.expiresIn, + Permissions: b.permissions, + }). + SetResult(&resp). + Post("/api/v2/service-token") + b.a.check("ServiceTokens.Create", r, err) + + return &ServiceTokenSeed{ID: resp.ServiceTokenData.ID, Name: resp.ServiceTokenData.Name, Token: resp.ServiceToken} +} + +// Delete removes a service token. +func (a ServiceTokensAPI) Delete(serviceTokenID string) { + a.t.Helper() + r, err := a.svc.client.R(). + SetAuthToken(a.svc.userToken). + Delete("/api/v2/service-token/" + serviceTokenID) + a.check("ServiceTokens.Delete", r, err) +} diff --git a/backend-go/tests/infra/nodejs/tags.go b/backend-go/tests/infra/nodejs/tags.go new file mode 100644 index 00000000000..49882ab116b --- /dev/null +++ b/backend-go/tests/infra/nodejs/tags.go @@ -0,0 +1,44 @@ +//go:build integration + +package nodejs + +import "fmt" + +// CreateTagRequest is the request body for POST /api/v1/projects/{id}/tags. +type CreateTagRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Color string `json:"color"` +} + +// CreateTagResponse is the response from POST /api/v1/projects/{id}/tags. +type CreateTagResponse struct { + Tag struct { + ID string `json:"id"` + } `json:"tag"` +} + +// TagSeed contains metadata for a tag created via the Node.js API. +type TagSeed struct { + ID string + Slug string + Name string +} + +// TagsAPI groups project tag endpoints. +type TagsAPI struct{ apiBase } + +// Create makes a project tag. +func (a TagsAPI) Create(projectID, slug, name, color string) *TagSeed { + a.t.Helper() + + var resp CreateTagResponse + r, err := a.svc.client.R(). + SetAuthToken(a.svc.identityToken). + SetBody(CreateTagRequest{Slug: slug, Name: name, Color: color}). + SetResult(&resp). + Post(fmt.Sprintf("/api/v1/projects/%s/tags", projectID)) + a.check("Tags.Create", r, err) + + return &TagSeed{ID: resp.Tag.ID, Slug: slug, Name: name} +} diff --git a/backend-go/tests/infra/nodejs/users.go b/backend-go/tests/infra/nodejs/users.go new file mode 100644 index 00000000000..af5bd9dfbf4 --- /dev/null +++ b/backend-go/tests/infra/nodejs/users.go @@ -0,0 +1,112 @@ +//go:build integration + +package nodejs + +import "context" + +// InviteToOrgRequest is the request body for POST /api/v1/invite-org/signup. +type InviteToOrgRequest struct { + InviteeEmails []string `json:"inviteeEmails"` + OrganizationID string `json:"organizationId"` +} + +// AddUserToProjectRequest is the request body for POST /api/v1/projects/{id}/memberships. +type AddUserToProjectRequest struct { + Usernames []string `json:"usernames"` + RoleSlugs []string `json:"roleSlugs"` +} + +// CreateUserPrivilegeRequest is the request body for POST /api/v1/user-project-additional-privilege. +type CreateUserPrivilegeRequest struct { + ProjectMembershipID string `json:"projectMembershipId"` + Permissions []Permission `json:"permissions"` + Type PrivilegeType `json:"type"` +} + +// UserSeed contains IDs for a user created via the Node.js API. +type UserSeed struct { + ID string + Email string + Token string // JWT access token for authentication +} + +// UsersAPI groups user endpoints. +type UsersAPI struct{ apiBase } + +// InviteAndCreate invites a user to the org and returns their seed. The invite +// creates the user record + org membership; the ID is read back from the DB +// since the invite response omits it. +func (a UsersAPI) InviteAndCreate(email string) *UserSeed { + a.t.Helper() + + r, err := a.svc.client.R(). + SetAuthToken(a.svc.userToken). + SetBody(InviteToOrgRequest{InviteeEmails: []string{email}, OrganizationID: a.svc.orgID}). + Post("/api/v1/invite-org/signup") + a.check("Users.InviteAndCreate", r, err) + + if a.svc.db == nil { + a.t.Fatal("nodejs.Users.InviteAndCreate: db is nil, cannot query user ID") + } + var userID string + if err := a.svc.db.Primary().QueryRow(context.Background(), + `SELECT id FROM users WHERE username = $1`, email).Scan(&userID); err != nil { + a.t.Fatalf("nodejs.Users.InviteAndCreate: query user ID: %v", err) + } + + return &UserSeed{ID: userID, Email: email} +} + +// AddToProject starts a user-to-project membership build keyed by email. +func (a UsersAPI) AddToProject(projectID, email string) *addUserBuilder { + return &addUserBuilder{a: a, projectID: projectID, email: email} +} + +type addUserBuilder struct { + a UsersAPI + projectID string + email string + roleSlugs []string +} + +// Role appends a role slug to assign. +func (b *addUserBuilder) Role(slug string) *addUserBuilder { + b.roleSlugs = append(b.roleSlugs, slug) + return b +} + +// Do assigns the configured roles. +func (b *addUserBuilder) Do() { + b.a.t.Helper() + r, err := b.a.svc.client.R(). + SetAuthToken(b.a.svc.identityToken). + SetBody(AddUserToProjectRequest{Usernames: []string{b.email}, RoleSlugs: b.roleSlugs}). + Post("/api/v1/projects/" + b.projectID + "/memberships") + b.a.check("Users.AddToProject", r, err) +} + +// AdditionalPrivilege creates a permanent additional privilege for a user in a +// project. The project membership ID is resolved from the DB. +func (a UsersAPI) AdditionalPrivilege(userID, projectID string, permissions ...Permission) { + a.t.Helper() + + if a.svc.db == nil { + a.t.Fatal("nodejs.Users.AdditionalPrivilege: db is nil") + } + var membershipID string + if err := a.svc.db.Primary().QueryRow(context.Background(), + `SELECT id FROM memberships WHERE "actorUserId" = $1 AND "scopeProjectId" = $2 AND scope = 'project'`, + userID, projectID).Scan(&membershipID); err != nil { + a.t.Fatalf("nodejs.Users.AdditionalPrivilege: query membership ID: %v", err) + } + + r, err := a.svc.client.R(). + SetAuthToken(a.svc.userToken). + SetBody(CreateUserPrivilegeRequest{ + ProjectMembershipID: membershipID, + Permissions: permissions, + Type: PrivilegeType{IsTemporary: false}, + }). + Post("/api/v1/user-project-additional-privilege/") + a.check("Users.AdditionalPrivilege", r, err) +} diff --git a/backend-go/tests/infra/nodejs_types.go b/backend-go/tests/infra/nodejs_types.go deleted file mode 100644 index bce6369f92f..00000000000 --- a/backend-go/tests/infra/nodejs_types.go +++ /dev/null @@ -1,345 +0,0 @@ -//go:build integration - -package infra - -// Request and response types for Node.js API calls. -// These provide type safety without requiring full OpenAPI code generation. - -// BootstrapRequest is the request body for POST /api/v1/admin/bootstrap. -type BootstrapRequest struct { - Email string `json:"email"` - Password string `json:"password"` - Organization string `json:"organization"` -} - -// BootstrapResponse is the response from POST /api/v1/admin/bootstrap. -type BootstrapResponse struct { - Organization struct { - ID string `json:"id"` - } `json:"organization"` - Identity struct { - Credentials struct { - Token string `json:"token"` - } `json:"credentials"` - } `json:"identity"` - User struct { - ID string `json:"id"` - Email string `json:"email"` - } `json:"user"` -} - -// LoginRequest is the request body for POST /api/v3/auth/login. -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -// LoginResponse is the response from POST /api/v3/auth/login. -type LoginResponse struct { - AccessToken string `json:"accessToken"` -} - -// SelectOrgRequest is the request body for POST /api/v3/auth/select-organization. -type SelectOrgRequest struct { - OrganizationID string `json:"organizationId"` -} - -// SelectOrgResponse is the response from POST /api/v3/auth/select-organization. -type SelectOrgResponse struct { - Token string `json:"token"` -} - -// CreateProjectRequest is the request body for POST /api/v1/projects. -type CreateProjectRequest struct { - ProjectName string `json:"projectName"` - Slug string `json:"slug"` - Type string `json:"type"` -} - -// CreateProjectResponse is the response from POST /api/v1/projects. -type CreateProjectResponse struct { - Project struct { - ID string `json:"id"` - Slug string `json:"slug"` - } `json:"project"` -} - -// CreateIdentityRequest is the request body for POST /api/v1/identities. -type CreateIdentityRequest struct { - Name string `json:"name"` - OrganizationID string `json:"organizationId"` - Role string `json:"role"` -} - -// CreateIdentityResponse is the response from POST /api/v1/identities. -type CreateIdentityResponse struct { - Identity struct { - ID string `json:"id"` - } `json:"identity"` -} - -// RoleAssignment represents a role with optional temporary access settings. -type RoleAssignment struct { - Role string `json:"role"` - IsTemporary bool `json:"isTemporary,omitempty"` - TemporaryMode string `json:"temporaryMode,omitempty"` - TemporaryRange string `json:"temporaryRange,omitempty"` - TemporaryAccessStartTime string `json:"temporaryAccessStartTime,omitempty"` -} - -// AddIdentityToProjectWithRolesRequest is the request body with roles array. -type AddIdentityToProjectWithRolesRequest struct { - Roles []RoleAssignment `json:"roles"` -} - -// InviteToOrgRequest is the request body for POST /api/v1/invite-org/signup. -type InviteToOrgRequest struct { - InviteeEmails []string `json:"inviteeEmails"` - OrganizationID string `json:"organizationId"` -} - -// AddUserToProjectRequest is the request body for POST /api/v1/projects/{id}/memberships. -type AddUserToProjectRequest struct { - Usernames []string `json:"usernames"` - RoleSlugs []string `json:"roleSlugs"` -} - -// Permission represents a CASL permission rule. -type Permission struct { - Subject string `json:"subject"` - Action any `json:"action"` - Conditions map[string]any `json:"conditions,omitempty"` - Inverted bool `json:"inverted,omitempty"` -} - -// CreateCustomRoleRequest is the request body for POST /api/v1/projects/{id}/roles. -type CreateCustomRoleRequest struct { - Slug string `json:"slug"` - Name string `json:"name"` - Permissions []Permission `json:"permissions"` -} - -// CreateCustomRoleResponse is the response from POST /api/v1/projects/{id}/roles. -type CreateCustomRoleResponse struct { - Role struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - } `json:"role"` -} - -// CreateGroupRequest is the request body for POST /api/v1/groups. -type CreateGroupRequest struct { - Name string `json:"name"` - Role string `json:"role"` -} - -// CreateGroupResponse is the response from POST /api/v1/groups. -type CreateGroupResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -// AddGroupToProjectRequest is the request body for POST /api/v1/projects/{id}/memberships/groups/{id}. -type AddGroupToProjectRequest struct { - Role string `json:"role"` -} - -// PrivilegeType specifies whether a privilege is temporary. -type PrivilegeType struct { - IsTemporary bool `json:"isTemporary"` - TemporaryMode string `json:"temporaryMode,omitempty"` - TemporaryRange string `json:"temporaryRange,omitempty"` - TemporaryAccessStartTime string `json:"temporaryAccessStartTime,omitempty"` -} - -// CreateIdentityPrivilegeRequest is the request body for POST /api/v2/identity-project-additional-privilege. -type CreateIdentityPrivilegeRequest struct { - IdentityID string `json:"identityId"` - ProjectID string `json:"projectId"` - Permissions []Permission `json:"permissions"` - Type PrivilegeType `json:"type"` -} - -// CreateUserPrivilegeRequest is the request body for POST /api/v1/user-project-additional-privilege. -type CreateUserPrivilegeRequest struct { - ProjectMembershipID string `json:"projectMembershipId"` - Permissions []Permission `json:"permissions"` - Type PrivilegeType `json:"type"` -} - -// SecretMetadataEntry represents a metadata key-value pair for a secret. -type SecretMetadataEntry struct { - Key string `json:"key"` - Value string `json:"value"` - IsEncrypted bool `json:"isEncrypted,omitempty"` -} - -// CreateSecretRequest is the request body for POST /api/v4/secrets/{key}. -type CreateSecretRequest struct { - ProjectID string `json:"projectId"` - Environment string `json:"environment"` - SecretPath string `json:"secretPath"` - SecretValue string `json:"secretValue"` - SecretComment string `json:"secretComment,omitempty"` - SecretMetadata []SecretMetadataEntry `json:"secretMetadata,omitempty"` - Type string `json:"type"` - TagIDs []string `json:"tagIds,omitempty"` - SecretReminderNote string `json:"secretReminderNote,omitempty"` - SecretReminderRepeatDays *int `json:"secretReminderRepeatDays,omitempty"` -} - -// CreateSecretResponse is the response from POST /api/v4/secrets/{key}. -type CreateSecretResponse struct { - Secret struct { - ID string `json:"id"` - } `json:"secret"` -} - -// GetSecretResponse is the response from GET /api/v4/secrets/{key}. -type GetSecretResponse struct { - Secret struct { - ID string `json:"id"` - Key string `json:"secretKey"` - Value string `json:"secretValue"` - Version int `json:"version"` - SecretPath string `json:"secretPath"` - Environment string `json:"environment"` - } `json:"secret"` -} - -// CreateFolderRequest is the request body for POST /api/v2/folders. -type CreateFolderRequest struct { - ProjectID string `json:"projectId"` - Environment string `json:"environment"` - Path string `json:"path"` - Name string `json:"name"` -} - -// CreateFolderResponse is the response from POST /api/v2/folders. -type CreateFolderResponse struct { - Folder struct { - ID string `json:"id"` - } `json:"folder"` -} - -// SecretImportTarget specifies the source for a secret import. -type SecretImportTarget struct { - Environment string `json:"environment"` - Path string `json:"path"` -} - -// CreateSecretImportRequest is the request body for POST /api/v2/secret-imports. -type CreateSecretImportRequest struct { - ProjectID string `json:"projectId"` - Environment string `json:"environment"` - Path string `json:"path"` - Import SecretImportTarget `json:"import"` -} - -// CreateSecretImportResponse is the response from POST /api/v2/secret-imports. -type CreateSecretImportResponse struct { - SecretImport struct { - ID string `json:"id"` - } `json:"secretImport"` -} - -// CreateEnvironmentRequest is the request body for POST /api/v1/projects/{id}/environments. -type CreateEnvironmentRequest struct { - Slug string `json:"slug"` - Name string `json:"name"` -} - -// CreateEnvironmentResponse is the response from POST /api/v1/projects/{id}/environments. -type CreateEnvironmentResponse struct { - Environment struct { - ID string `json:"id"` - } `json:"environment"` -} - -// CreateTagRequest is the request body for POST /api/v1/projects/{id}/tags. -type CreateTagRequest struct { - Slug string `json:"slug"` - Name string `json:"name"` - Color string `json:"color"` -} - -// CreateTagResponse is the response from POST /api/v1/projects/{id}/tags. -type CreateTagResponse struct { - Tag struct { - ID string `json:"id"` - } `json:"tag"` -} - -// IPAddress represents a trusted IP address. -type IPAddress struct { - IPAddress string `json:"ipAddress"` -} - -// CreateUniversalAuthRequest is the request body for POST /api/v1/auth/universal-auth/identities/{id}. -type CreateUniversalAuthRequest struct { - IdentityID string `json:"identityId"` - AccessTokenTrustedIPs []IPAddress `json:"accessTokenTrustedIps"` - AccessTokenTTL int `json:"accessTokenTTL"` - AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` - AccessTokenNumUsesLimit int `json:"accessTokenNumUsesLimit"` - ClientSecretTrustedIPs []IPAddress `json:"clientSecretTrustedIps"` - ClientSecretNumUsesLimit int `json:"clientSecretNumUsesLimit"` - IsClientSecretRotationEnabled bool `json:"isClientSecretRotationEnabled"` -} - -// CreateUniversalAuthResponse is the response from POST /api/v1/auth/universal-auth/identities/{id}. -type CreateUniversalAuthResponse struct { - IdentityUniversalAuth struct { - ID string `json:"id"` - ClientID string `json:"clientId"` - } `json:"identityUniversalAuth"` -} - -// CreateClientSecretRequest is the request body for POST /api/v1/auth/universal-auth/identities/{id}/client-secrets. -type CreateClientSecretRequest struct { - Description string `json:"description"` - TTL int `json:"ttl"` - NumUsesLimit int `json:"numUsesLimit"` -} - -// CreateClientSecretResponse is the response from POST /api/v1/auth/universal-auth/identities/{id}/client-secrets. -// Note: clientId is NOT in this response - it's in the universal auth creation response. -type CreateClientSecretResponse struct { - ClientSecretData struct { - ID string `json:"id"` - ClientSecretPrefix string `json:"clientSecretPrefix"` - } `json:"clientSecretData"` - ClientSecret string `json:"clientSecret"` -} - -// UniversalAuthLoginRequest is the request body for POST /api/v1/auth/universal-auth/login. -type UniversalAuthLoginRequest struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` -} - -// UniversalAuthLoginResponse is the response from POST /api/v1/auth/universal-auth/login. -type UniversalAuthLoginResponse struct { - AccessToken string `json:"accessToken"` -} - -// CreateOrgRequest is the request body for POST /api/v1/organizations. -type CreateOrgRequest struct { - Name string `json:"name"` -} - -// CreateOrgResponse is the response from POST /api/v1/organizations. -type CreateOrgResponse struct { - Organization struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"organization"` -} - -// OrgSeed contains metadata for an org created via the Node.js API. -type OrgSeed struct { - ID string - Name string -} diff --git a/backend-go/tests/infra/stack.go b/backend-go/tests/infra/stack.go index ad2c1bd50e3..c9f75fc77e9 100644 --- a/backend-go/tests/infra/stack.go +++ b/backend-go/tests/infra/stack.go @@ -12,6 +12,7 @@ import ( "github.com/infisical/api/internal/config" "github.com/infisical/api/internal/database/pg" + "github.com/infisical/api/tests/infra/nodejs" ) // Stack holds references to running containers and provides accessors @@ -19,7 +20,7 @@ import ( type Stack struct { postgres *PostgresService redis *RedisService - nodejs *NodeJSService + nodeJS *nodejs.Service network *testcontainers.DockerNetwork cfg *config.Config db pg.DB @@ -27,7 +28,7 @@ type Stack struct { func (s *Stack) Postgres() *PostgresService { return s.postgres } func (s *Stack) Redis() *RedisService { return s.redis } -func (s *Stack) NodeJS() *NodeJSService { return s.nodejs } +func (s *Stack) NodeJS() *nodejs.Service { return s.nodeJS } func (s *Stack) Config() *config.Config { return s.cfg } func (s *Stack) DB() pg.DB { return s.db } @@ -38,8 +39,8 @@ func (s *Stack) Stop() { if s.db != nil { s.db.Close() } - if s.nodejs != nil { - if err := s.nodejs.container.Terminate(ctx); err != nil { + if s.nodeJS != nil { + if err := s.nodeJS.Terminate(ctx); err != nil { log.Printf("infra.Stop: terminate nodejs: %v", err) } } diff --git a/backend-go/tests/platform/auth/apiauthenticator_test.go b/backend-go/tests/platform/auth/apiauthenticator_test.go deleted file mode 100644 index 78545850612..00000000000 --- a/backend-go/tests/platform/auth/apiauthenticator_test.go +++ /dev/null @@ -1,1263 +0,0 @@ -//go:build integration - -package auth_test - -import ( - "context" - "encoding/base64" - "errors" - "log/slog" - "os" - "strings" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/keystore" - "github.com/infisical/api/internal/libs/errutil" - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/internal/services/auth/apiauth" - "github.com/infisical/api/tests/infra" -) - -var ( - stack *infra.Stack - authenticator *apiauth.ApiAuthenticator - memKeyStore *keystore.MemoryKeyStore -) - -func TestMain(m *testing.M) { - stack = infra.New(). - WithPostgres(). - WithRedis(). - WithNodeJSApi(). - MustStart() - - memKeyStore = keystore.NewMemoryKeyStore() - authenticator = apiauth.NewApiAuthenticator(slog.Default(), stack.DB(), infra.AuthSecret, memKeyStore, nil, infra.NewNopErrorHandler()) - - code := m.Run() - stack.Stop() - os.Exit(code) -} - -// ============================================================================= -// JWT Signing Helpers -// ============================================================================= - -func signUserJWT(t *testing.T, secret string, mut func(*apiauth.UserJWTClaims)) string { - t.Helper() - claims := &apiauth.UserJWTClaims{ - AuthTokenType: auth.AuthTokenTypeAccessToken, - UserID: uuid.MustParse(stack.NodeJS().UserID()), - TokenVersionID: uuid.New(), - AccessVersion: 1, - OrganizationID: uuid.MustParse(stack.NodeJS().OrgID()), - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - if mut != nil { - mut(claims) - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(secret)) - require.NoError(t, err) - return tokenString -} - -func signIdentityJWT(t *testing.T, secret string, mut func(*apiauth.IdentityJWTClaims)) string { - t.Helper() - claims := &apiauth.IdentityJWTClaims{ - AuthTokenType: auth.AuthTokenTypeIdentityAccessToken, - IdentityID: uuid.New(), - IdentityAccessTokenID: uuid.New().String(), - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - if mut != nil { - mut(claims) - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(secret)) - require.NoError(t, err) - return tokenString -} - -// ============================================================================= -// Error Assertion Helpers -// ============================================================================= - -func assertUnauthorized(t *testing.T, err error) { - t.Helper() - require.Error(t, err) - var appErr *errutil.Error - if errors.As(err, &appErr) { - assert.Equal(t, 401, appErr.Status) - } -} - -func assertNotFound(t *testing.T, err error) { - t.Helper() - require.Error(t, err) - var appErr *errutil.Error - if errors.As(err, &appErr) { - assert.Equal(t, 404, appErr.Status) - } -} - -func assertErrorName(t *testing.T, err error, expectedName string) { - t.Helper() - require.Error(t, err) - var appErr *errutil.Error - if errors.As(err, &appErr) { - assert.Equal(t, expectedName, appErr.Name) - } -} - -// ============================================================================= -// JWT User Token Tests -// ============================================================================= - -func TestValidateJWT_UserToken_Valid(t *testing.T) { - token := stack.NodeJS().UserToken() - - identity, err := authenticator.ValidateJWT(context.Background(), token) - - require.NoError(t, err) - require.NotNil(t, identity) - assert.Equal(t, auth.AuthModeJWT, identity.AuthMode) - assert.Equal(t, auth.ActorTypeUser, identity.Actor) - assert.Equal(t, uuid.MustParse(stack.NodeJS().UserID()), identity.ActorID) - assert.Equal(t, uuid.MustParse(stack.NodeJS().OrgID()), identity.OrgID) - // Verify user-specific fields are populated - require.NotNil(t, identity.UserAuthInfo) - assert.NotEmpty(t, identity.Email) -} - -func TestValidateJWT_UserToken_Errors(t *testing.T) { - tests := []struct { - name string - token func(t *testing.T) string - assertError func(t *testing.T, err error) - }{ - { - name: "invalid signature", - token: func(t *testing.T) string { - return signUserJWT(t, "wrong-secret", nil) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "Invalid JWT") - }, - }, - { - name: "expired token", - token: func(t *testing.T) string { - return signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Hour)) - }) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "Invalid JWT") - }, - }, - { - name: "wrong auth token type", - token: func(t *testing.T) string { - return signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.AuthTokenType = "wrong-type" - }) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - }, - }, - { - name: "session not found", - token: func(t *testing.T) string { - return signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.TokenVersionID = uuid.New() // non-existent session - }) - }, - assertError: func(t *testing.T, err error) { - assertNotFound(t, err) - assert.Contains(t, err.Error(), "Session not found") - }, - }, - { - name: "user not found", - token: func(t *testing.T) string { - return signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.UserID = uuid.New() // non-existent user - }) - }, - assertError: func(t *testing.T, err error) { - assertNotFound(t, err) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - token := tt.token(t) - _, err := authenticator.ValidateJWT(context.Background(), token) - tt.assertError(t, err) - }) - } -} - -func TestValidateJWT_UserToken_StaleAccessVersion(t *testing.T) { - userID := uuid.MustParse(stack.NodeJS().UserID()) - sessionID := uuid.New() - - // Insert a session with access_version = 5 - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO auth_token_sessions (id, "userId", "accessVersion", "refreshVersion", ip, "userAgent", "lastUsed", "createdAt", "updatedAt") - VALUES (@sessionID, @userID, 5, 1, '127.0.0.1', 'test', NOW(), NOW(), NOW()) - `, pgx.NamedArgs{"sessionID": sessionID, "userID": userID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM auth_token_sessions WHERE id = @sessionID`, - pgx.NamedArgs{"sessionID": sessionID}) - }) - - // Create token with access_version = 1 (stale) - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.TokenVersionID = sessionID - c.AccessVersion = 1 // Different from DB's 5 - }) - - _, err = authenticator.ValidateJWT(context.Background(), token) - - assertUnauthorized(t, err) - assertErrorName(t, err, "StaleSession") - assert.Contains(t, err.Error(), "stale") -} - -func TestValidateJWT_UserToken_UserLocked(t *testing.T) { - lockedUserID := uuid.New() - testEmail := "locked-" + uuid.New().String() + "@test.com" - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO users (id, email, username, "isAccepted", "isLocked", "createdAt", "updatedAt") - VALUES (@userID, @email, @username, true, true, NOW(), NOW()) - `, pgx.NamedArgs{"userID": lockedUserID, "email": testEmail, "username": testEmail}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM users WHERE id = @userID`, - pgx.NamedArgs{"userID": lockedUserID}) - }) - - sessionID := uuid.New() - _, err = stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO auth_token_sessions (id, "userId", "accessVersion", "refreshVersion", ip, "userAgent", "lastUsed", "createdAt", "updatedAt") - VALUES (@sessionID, @userID, 1, 1, '127.0.0.1', 'test', NOW(), NOW(), NOW()) - `, pgx.NamedArgs{"sessionID": sessionID, "userID": lockedUserID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM auth_token_sessions WHERE id = @sessionID`, - pgx.NamedArgs{"sessionID": sessionID}) - }) - - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.UserID = lockedUserID - c.TokenVersionID = sessionID - c.AccessVersion = 1 - }) - - _, err = authenticator.ValidateJWT(context.Background(), token) - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "locked") -} - -func TestValidateJWT_UserToken_UserTemporarilyLocked(t *testing.T) { - tempLockedUserID := uuid.New() - testEmail := "templocked-" + uuid.New().String() + "@test.com" - lockEnd := time.Now().Add(time.Hour) - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO users (id, email, username, "isAccepted", "temporaryLockDateEnd", "createdAt", "updatedAt") - VALUES (@userID, @email, @username, true, @lockEnd, NOW(), NOW()) - `, pgx.NamedArgs{"userID": tempLockedUserID, "email": testEmail, "username": testEmail, "lockEnd": lockEnd}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM users WHERE id = @userID`, - pgx.NamedArgs{"userID": tempLockedUserID}) - }) - - sessionID := uuid.New() - _, err = stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO auth_token_sessions (id, "userId", "accessVersion", "refreshVersion", ip, "userAgent", "lastUsed", "createdAt", "updatedAt") - VALUES (@sessionID, @userID, 1, 1, '127.0.0.1', 'test', NOW(), NOW(), NOW()) - `, pgx.NamedArgs{"sessionID": sessionID, "userID": tempLockedUserID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM auth_token_sessions WHERE id = @sessionID`, - pgx.NamedArgs{"sessionID": sessionID}) - }) - - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.UserID = tempLockedUserID - c.TokenVersionID = sessionID - c.AccessVersion = 1 - }) - - _, err = authenticator.ValidateJWT(context.Background(), token) - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "locked") -} - -func TestValidateJWT_UserToken_UserNotAccepted(t *testing.T) { - notAcceptedUserID := uuid.New() - testEmail := "notaccepted-" + uuid.New().String() + "@test.com" - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO users (id, email, username, "isAccepted", "createdAt", "updatedAt") - VALUES (@userID, @email, @username, false, NOW(), NOW()) - `, pgx.NamedArgs{"userID": notAcceptedUserID, "email": testEmail, "username": testEmail}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM users WHERE id = @userID`, - pgx.NamedArgs{"userID": notAcceptedUserID}) - }) - - sessionID := uuid.New() - _, err = stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO auth_token_sessions (id, "userId", "accessVersion", "refreshVersion", ip, "userAgent", "lastUsed", "createdAt", "updatedAt") - VALUES (@sessionID, @userID, 1, 1, '127.0.0.1', 'test', NOW(), NOW(), NOW()) - `, pgx.NamedArgs{"sessionID": sessionID, "userID": notAcceptedUserID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM auth_token_sessions WHERE id = @sessionID`, - pgx.NamedArgs{"sessionID": sessionID}) - }) - - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.UserID = notAcceptedUserID - c.TokenVersionID = sessionID - c.AccessVersion = 1 - }) - - _, err = authenticator.ValidateJWT(context.Background(), token) - - assertNotFound(t, err) -} - -// ============================================================================= -// Identity Access Token Tests -// ============================================================================= - -func TestValidateIdentityAccessToken_Valid(t *testing.T) { - token := stack.NodeJS().IdentityToken() - - identity, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - - require.NoError(t, err) - require.NotNil(t, identity) - assert.Equal(t, auth.AuthModeIdentityAccessToken, identity.AuthMode) - assert.Equal(t, auth.ActorTypeIdentity, identity.Actor) - assert.NotEqual(t, uuid.Nil, identity.ActorID) - assert.Equal(t, uuid.MustParse(stack.NodeJS().OrgID()), identity.OrgID) - require.NotNil(t, identity.IdentityAuthInfo) - assert.NotEqual(t, uuid.Nil, identity.IdentityAuthInfo.IdentityID) -} - -func TestValidateIdentityAccessToken_Errors(t *testing.T) { - tests := []struct { - name string - token func(t *testing.T) string - assertError func(t *testing.T, err error) - }{ - { - name: "invalid signature", - token: func(t *testing.T) string { - return signIdentityJWT(t, "wrong-secret", nil) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - }, - }, - { - name: "expired token", - token: func(t *testing.T) string { - return signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Hour)) - }) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - }, - }, - { - name: "wrong auth token type", - token: func(t *testing.T) string { - return signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.AuthTokenType = "wrong-type" - }) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - }, - }, - { - name: "token not found in DB (legacy path)", - token: func(t *testing.T) string { - return signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.IdentityAccessTokenID = uuid.New().String() - }) - }, - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "Cannot renew revoked or unknown access token") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - token := tt.token(t) - _, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - tt.assertError(t, err) - }) - } -} - -func TestValidateIdentityAccessToken_CustomIdentity(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "identity-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - authIdentity, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - - require.NoError(t, err) - require.NotNil(t, authIdentity) - assert.Equal(t, auth.AuthModeIdentityAccessToken, authIdentity.AuthMode) - assert.Equal(t, auth.ActorTypeIdentity, authIdentity.Actor) - assert.Equal(t, uuid.MustParse(identity.ID), authIdentity.ActorID) -} - -func TestValidateIdentityAccessToken_Revoked(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-revoke-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "identity-revoke-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - nodejs.RevokeAccessToken(t, token) - - _, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "revoked") -} - -func TestValidateIdentityAccessToken_IdentityDeleted(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-idelete-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "identity-idelete-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - nodejs.DeleteIdentity(t, identity.ID) - - _, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - - require.Error(t, err) -} - -// ============================================================================= -// Service Token Tests -// ============================================================================= - -func TestValidateServiceToken_Errors(t *testing.T) { - tests := []struct { - name string - token string - assertError func(t *testing.T, err error) - }{ - { - name: "not found", - token: "st." + uuid.New().String() + ".fakesecret123", - assertError: func(t *testing.T, err error) { - assertNotFound(t, err) - }, - }, - { - name: "invalid format - missing parts", - token: "st.onlyonepart", - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - }, - }, - { - name: "invalid format - wrong prefix", - token: "wrong." + uuid.New().String() + ".secret", - assertError: func(t *testing.T, err error) { - assertUnauthorized(t, err) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := authenticator.ValidateServiceToken(context.Background(), tt.token) - tt.assertError(t, err) - }) - } -} - -func TestValidateServiceToken_Valid(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-st-valid-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - st := nodejs.CreateServiceToken(t, proj.ID, nil) - t.Cleanup(func() { - nodejs.DeleteServiceToken(t, st.ID) - }) - - identity, err := authenticator.ValidateServiceToken(context.Background(), st.Token) - - require.NoError(t, err) - require.NotNil(t, identity) - assert.Equal(t, auth.AuthModeServiceToken, identity.AuthMode) - assert.Equal(t, auth.ActorTypeService, identity.Actor) - assert.Equal(t, st.ID, identity.ActorID.String()) -} - -func TestValidateServiceToken_WrongSecret(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-st-wrongsec-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - st := nodejs.CreateServiceToken(t, proj.ID, nil) - t.Cleanup(func() { - nodejs.DeleteServiceToken(t, st.ID) - }) - - parts := strings.SplitN(st.Token, ".", 3) - require.Len(t, parts, 3) - wrongToken := "st." + parts[1] + ".wrong-secret-here" - - _, err := authenticator.ValidateServiceToken(context.Background(), wrongToken) - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "Invalid service token") -} - -func TestValidateServiceToken_Expired(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-st-expired-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - expiresIn := 1 - st := nodejs.CreateServiceToken(t, proj.ID, &infra.CreateServiceTokenOpts{ExpiresIn: &expiresIn}) - - time.Sleep(2 * time.Second) - - _, err := authenticator.ValidateServiceToken(context.Background(), st.Token) - - require.Error(t, err) -} - -// ============================================================================= -// Identity Token numUsesLimit Tests -// ============================================================================= - -func TestValidateIdentityAccessToken_NumUsesLimitExhausted(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-numlimit-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "identity-numlimit-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - claims := &apiauth.IdentityJWTClaims{} - _, _ = jwt.ParseWithClaims(token, claims, func(_ *jwt.Token) (any, error) { - return []byte(infra.AuthSecret), nil - }) - - tokenID := claims.ID - if tokenID == "" { - tokenID = claims.IdentityAccessTokenID - } - - key := "identity-token-uses-remaining:" + identity.ID + ":" + tokenID - err := memKeyStore.SetItem(context.Background(), key, "0") - require.NoError(t, err) - - tokenWithLimit := signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.IdentityID = uuid.MustParse(identity.ID) - c.OrgID = uuid.MustParse(stack.NodeJS().OrgID()) - c.RootOrgID = uuid.MustParse(stack.NodeJS().OrgID()) - c.ParentOrgID = uuid.MustParse(stack.NodeJS().OrgID()) - c.AuthMethod = "universal-auth" - c.AccessTokenTTL = 3600 - c.NumUsesLimit = 5 - c.ID = tokenID - }) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), tokenWithLimit, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "usage limit") -} - -// ============================================================================= -// JWT Algorithm Attack Tests -// ============================================================================= - -func TestValidateJWT_UserToken_AlgNoneAttack(t *testing.T) { - header := base64URLEncode(`{"alg":"none","typ":"JWT"}`) - payload := base64URLEncode(`{"authTokenType":"accessToken","userId":"` + stack.NodeJS().UserID() + `"}`) - algNoneToken := header + "." + payload + "." - - _, err := authenticator.ValidateJWT(context.Background(), algNoneToken) - - assertUnauthorized(t, err) -} - -func TestValidateJWT_UserToken_WrongAlgorithm(t *testing.T) { - token := jwt.NewWithClaims(jwt.SigningMethodHS384, &apiauth.UserJWTClaims{ - AuthTokenType: auth.AuthTokenTypeAccessToken, - UserID: uuid.MustParse(stack.NodeJS().UserID()), - TokenVersionID: uuid.New(), - AccessVersion: 1, - OrganizationID: uuid.MustParse(stack.NodeJS().OrgID()), - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - }) - tokenString, err := token.SignedString([]byte(infra.AuthSecret)) - require.NoError(t, err) - - _, err = authenticator.ValidateJWT(context.Background(), tokenString) - - assertUnauthorized(t, err) -} - -// ============================================================================= -// Legacy Identity Token Tests -// ============================================================================= - -func TestValidateIdentityAccessToken_LegacyRevoked(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-legacy-rev-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "identity-legacy-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - tokenID := uuid.New() - identityID := uuid.MustParse(identity.ID) - - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO identity_access_tokens ( - id, "identityId", "isAccessTokenRevoked", "accessTokenTTL", "accessTokenMaxTTL", - "accessTokenNumUses", "accessTokenNumUsesLimit", "authMethod", "createdAt", "updatedAt" - ) VALUES ( - @tokenID, @identityID, true, 3600, 7200, 0, 0, 'universal-auth', NOW(), NOW() - ) - `, pgx.NamedArgs{"tokenID": tokenID, "identityID": identityID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM identity_access_tokens WHERE id = @tokenID`, - pgx.NamedArgs{"tokenID": tokenID}) - }) - - legacyToken := signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.IdentityID = identityID - c.IdentityAccessTokenID = tokenID.String() - c.OrgID = uuid.Nil - c.RootOrgID = uuid.Nil - c.ParentOrgID = uuid.Nil - c.AuthMethod = "" - c.AccessTokenTTL = 0 - c.AccessTokenMaxTTL = 0 - c.AccessTokenPeriod = 0 - }) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), legacyToken, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "revoked") -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -func base64URLEncode(s string) string { - encoded := base64.RawURLEncoding.EncodeToString([]byte(s)) - return encoded -} - -// ============================================================================= -// Malformed Token Tests -// ============================================================================= - -func TestValidateJWT_MalformedTokens(t *testing.T) { - tests := []struct { - name string - token string - }{ - {"single dot", "."}, - {"two dots", ".."}, - {"three dots", "..."}, - {"only header", "eyJhbGciOiJIUzI1NiJ9"}, - {"header and dot", "eyJhbGciOiJIUzI1NiJ9."}, - {"random string", "not-a-token-at-all"}, - {"base64 garbage", "!!!.@@@.###"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := authenticator.ValidateJWT(context.Background(), tt.token) - require.Error(t, err) - }) - } -} - -// ============================================================================= -// ValidateJWTToken (Unified Entry Point) Tests -// ============================================================================= - -func TestValidateJWTToken_RoutesToUserToken(t *testing.T) { - token := stack.NodeJS().UserToken() - - identity, authMode, err := authenticator.ValidateJWTToken(context.Background(), token, "") - - require.NoError(t, err) - assert.Equal(t, auth.AuthModeJWT, authMode) - assert.Equal(t, auth.ActorTypeUser, identity.Actor) -} - -func TestValidateJWTToken_RoutesToIdentityToken(t *testing.T) { - token := stack.NodeJS().IdentityToken() - - identity, authMode, err := authenticator.ValidateJWTToken(context.Background(), token, "") - - require.NoError(t, err) - assert.Equal(t, auth.AuthModeIdentityAccessToken, authMode) - assert.Equal(t, auth.ActorTypeIdentity, identity.Actor) -} - -func TestValidateJWTToken_UnsupportedTokenType(t *testing.T) { - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.AuthTokenType = "unsupported-type" - }) - - _, _, err := authenticator.ValidateJWTToken(context.Background(), token, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "Unsupported token type") -} - -// ============================================================================= -// User Org Membership Tests -// ============================================================================= - -func TestValidateJWT_UserToken_NotMemberOfOrg(t *testing.T) { - // Create a new user that is NOT a member of the org in the JWT claims - newUserID := uuid.New() - testEmail := "notmember-" + uuid.New().String() + "@test.com" - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO users (id, email, username, "isAccepted", "createdAt", "updatedAt") - VALUES (@userID, @email, @username, true, NOW(), NOW()) - `, pgx.NamedArgs{"userID": newUserID, "email": testEmail, "username": testEmail}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM users WHERE id = @userID`, - pgx.NamedArgs{"userID": newUserID}) - }) - - sessionID := uuid.New() - _, err = stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO auth_token_sessions (id, "userId", "accessVersion", "refreshVersion", ip, "userAgent", "lastUsed", "createdAt", "updatedAt") - VALUES (@sessionID, @userID, 1, 1, '127.0.0.1', 'test', NOW(), NOW(), NOW()) - `, pgx.NamedArgs{"sessionID": sessionID, "userID": newUserID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM auth_token_sessions WHERE id = @sessionID`, - pgx.NamedArgs{"sessionID": sessionID}) - }) - - // Create token with an org the user is not a member of - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.UserID = newUserID - c.TokenVersionID = sessionID - c.AccessVersion = 1 - c.OrganizationID = uuid.MustParse(stack.NodeJS().OrgID()) // user is not a member - }) - - _, err = authenticator.ValidateJWT(context.Background(), token) - - require.Error(t, err) - var appErr *errutil.Error - require.ErrorAs(t, err, &appErr) - assert.Equal(t, 403, appErr.Status) - assert.Contains(t, err.Error(), "not member") -} - -func TestValidateJWT_UserToken_OrgMembershipInactive(t *testing.T) { - // Create a user directly in DB with isAccepted=true - userID := uuid.New() - testEmail := "inactive-member-" + uuid.New().String()[:8] + "@test.com" - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO users (id, email, username, "isAccepted", "createdAt", "updatedAt") - VALUES (@userID, @email, @username, true, NOW(), NOW()) - `, pgx.NamedArgs{"userID": userID, "email": testEmail, "username": testEmail}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM users WHERE id = @userID`, - pgx.NamedArgs{"userID": userID}) - }) - - // Create org membership with isActive=false - orgID := uuid.MustParse(stack.NodeJS().OrgID()) - membershipID := uuid.New() - _, err = stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO memberships (id, "actorUserId", scope, "scopeOrgId", "isActive", status, "createdAt", "updatedAt") - VALUES (@membershipID, @userID, 'organization', @orgID, false, 'accepted', NOW(), NOW()) - `, pgx.NamedArgs{"membershipID": membershipID, "userID": userID, "orgID": orgID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM memberships WHERE id = @membershipID`, - pgx.NamedArgs{"membershipID": membershipID}) - }) - - // Create session - sessionID := uuid.New() - _, err = stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO auth_token_sessions (id, "userId", "accessVersion", "refreshVersion", ip, "userAgent", "lastUsed", "createdAt", "updatedAt") - VALUES (@sessionID, @userID, 1, 1, '127.0.0.1', 'test', NOW(), NOW(), NOW()) - `, pgx.NamedArgs{"sessionID": sessionID, "userID": userID}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM auth_token_sessions WHERE id = @sessionID`, - pgx.NamedArgs{"sessionID": sessionID}) - }) - - token := signUserJWT(t, infra.AuthSecret, func(c *apiauth.UserJWTClaims) { - c.UserID = userID - c.TokenVersionID = sessionID - c.AccessVersion = 1 - c.OrganizationID = orgID - }) - - _, err = authenticator.ValidateJWT(context.Background(), token) - - require.Error(t, err) - var appErr *errutil.Error - require.ErrorAs(t, err, &appErr) - assert.Equal(t, 403, appErr.Status) - assert.Contains(t, err.Error(), "inactive") -} - -// ============================================================================= -// Identity Org Membership Tests -// ============================================================================= - -func TestValidateIdentityAccessToken_NotMemberOfOrg(t *testing.T) { - nodejs := stack.NodeJS() - - // Create an identity but do NOT add it to any project - identity := nodejs.CreateIdentity(t, "orphan-identity-"+uuid.New().String()[:8]) - - // Get a token for this identity (this creates universal auth) - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - // Remove the identity from the org membership - _, err := stack.DB().Primary().Exec(context.Background(), ` - DELETE FROM memberships - WHERE "actorIdentityId" = @identityID - `, pgx.NamedArgs{"identityID": uuid.MustParse(identity.ID)}) - require.NoError(t, err) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "not a member") -} - -func TestValidateIdentityAccessToken_OrgMembershipInactive(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-inactive-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "inactive-identity-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - // Deactivate the org membership - _, err := stack.DB().Primary().Exec(context.Background(), ` - UPDATE memberships - SET "isActive" = false - WHERE "actorIdentityId" = @identityID AND scope = 'organization' - `, pgx.NamedArgs{"identityID": uuid.MustParse(identity.ID)}) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `UPDATE memberships SET "isActive" = true WHERE "actorIdentityId" = @identityID`, - pgx.NamedArgs{"identityID": uuid.MustParse(identity.ID)}) - }) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), token, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "inactive") -} - -// ============================================================================= -// Identity IP Blocklist Tests -// ============================================================================= - -func TestValidateIdentityAccessToken_IPBlocked(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-ipblock-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "ipblock-identity-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - // Get an access token (this creates universal auth with 0.0.0.0/0) - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - // Update the trusted IPs to only allow a specific IP (not 192.168.1.1) - // Use proper JSON format matching what Infisical stores. - // No "prefix" field means exact IP match; "prefix":0 would mean /0 (all IPs). - _, err := stack.DB().Primary().Exec(context.Background(), ` - UPDATE identity_universal_auths - SET "accessTokenTrustedIps" = '[{"ipAddress":"10.0.0.1","type":"ipv4"}]'::jsonb - WHERE "identityId" = @identityID - `, pgx.NamedArgs{"identityID": uuid.MustParse(identity.ID)}) - require.NoError(t, err) - - // Verify the update took effect - var storedIPs string - err = stack.DB().Primary().QueryRow(context.Background(), ` - SELECT "accessTokenTrustedIps"::text FROM identity_universal_auths WHERE "identityId" = @identityID - `, pgx.NamedArgs{"identityID": uuid.MustParse(identity.ID)}).Scan(&storedIPs) - require.NoError(t, err) - require.Contains(t, storedIPs, "10.0.0.1", "DB should have the updated IP") - - // Try to validate with an IP not in the allowlist - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), token, "192.168.1.1") - - require.Error(t, err) - var appErr *errutil.Error - require.ErrorAs(t, err, &appErr) - assert.Equal(t, 403, appErr.Status) - assert.Contains(t, err.Error(), "IP address") -} - -func TestValidateIdentityAccessToken_IPAllowed(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-ipallow-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "ipallow-identity-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - // Update the trusted IPs to allow a specific CIDR - _, err := stack.DB().Primary().Exec(context.Background(), ` - UPDATE identity_universal_auths - SET "accessTokenTrustedIps" = '[{"ipAddress":"192.168.0.0","prefix":16}]' - WHERE "identityId" = @identityID - `, pgx.NamedArgs{"identityID": uuid.MustParse(identity.ID)}) - require.NoError(t, err) - - // Validate with an IP within the CIDR - authIdentity, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "192.168.1.100") - - require.NoError(t, err) - assert.Equal(t, uuid.MustParse(identity.ID), authIdentity.ActorID) -} - -func TestValidateIdentityAccessToken_NoIPCheckWhenEmpty(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-noipcheck-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "noipcheck-identity-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - token := nodejs.GetIdentityAccessToken(t, identity.ID) - - // The default is 0.0.0.0/0 which allows all - validate works with any IP - authIdentity, err := authenticator.ValidateIdentityAccessToken(context.Background(), token, "8.8.8.8") - - require.NoError(t, err) - assert.Equal(t, uuid.MustParse(identity.ID), authIdentity.ActorID) -} - -// ============================================================================= -// Service Token Edge Cases -// ============================================================================= - -func TestValidateServiceToken_ProjectNotFound(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-st-projdel-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - - st := nodejs.CreateServiceToken(t, proj.ID, nil) - - // Delete the project (this should cascade delete the service token too, - // but let's manually delete just the project row to simulate orphaned token) - _, err := stack.DB().Primary().Exec(context.Background(), ` - DELETE FROM projects WHERE id = @projectID - `, pgx.NamedArgs{"projectID": proj.ID}) - require.NoError(t, err) - - _, err = authenticator.ValidateServiceToken(context.Background(), st.Token) - - // Either not found (token cascade deleted) or project not found - require.Error(t, err) -} - -// ============================================================================= -// Legacy Identity Token Constraints (Integration) -// ============================================================================= - -func TestValidateIdentityAccessToken_LegacyTTLExpired(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-legacy-ttl-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "legacy-ttl-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - tokenID := uuid.New() - identityID := uuid.MustParse(identity.ID) - - // Insert a legacy token with expired TTL (created 2 hours ago, TTL 1 hour) - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO identity_access_tokens ( - id, "identityId", "isAccessTokenRevoked", "accessTokenTTL", "accessTokenMaxTTL", - "accessTokenNumUses", "accessTokenNumUsesLimit", "accessTokenLastRenewedAt", - "authMethod", "createdAt", "updatedAt" - ) VALUES ( - @tokenID, @identityID, false, 3600, 0, 0, 0, @lastRenewed, - 'universal-auth', @createdAt, NOW() - ) - `, pgx.NamedArgs{ - "tokenID": tokenID, - "identityID": identityID, - "lastRenewed": time.Now().Add(-2 * time.Hour), - "createdAt": time.Now().Add(-2 * time.Hour), - }) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM identity_access_tokens WHERE id = @tokenID`, - pgx.NamedArgs{"tokenID": tokenID}) - }) - - // Create a legacy token (without full renew claims) - legacyToken := signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.IdentityID = identityID - c.IdentityAccessTokenID = tokenID.String() - // No OrgID, AuthMethod, etc. - makes it a legacy token - c.OrgID = uuid.Nil - c.AuthMethod = "" - c.AccessTokenTTL = 0 - }) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), legacyToken, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "TTL expired") -} - -func TestValidateIdentityAccessToken_LegacyMaxTTLExpired(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-legacy-maxttl-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "legacy-maxttl-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - tokenID := uuid.New() - identityID := uuid.MustParse(identity.ID) - - // Insert a legacy token with expired maxTTL (created 3 hours ago, maxTTL 2 hours) - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO identity_access_tokens ( - id, "identityId", "isAccessTokenRevoked", "accessTokenTTL", "accessTokenMaxTTL", - "accessTokenNumUses", "accessTokenNumUsesLimit", "accessTokenLastRenewedAt", - "authMethod", "createdAt", "updatedAt" - ) VALUES ( - @tokenID, @identityID, false, 86400, 7200, 0, 0, @lastRenewed, - 'universal-auth', @createdAt, NOW() - ) - `, pgx.NamedArgs{ - "tokenID": tokenID, - "identityID": identityID, - "lastRenewed": time.Now().Add(-1 * time.Minute), // recently renewed (TTL ok) - "createdAt": time.Now().Add(-3 * time.Hour), // but created too long ago (maxTTL expired) - }) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM identity_access_tokens WHERE id = @tokenID`, - pgx.NamedArgs{"tokenID": tokenID}) - }) - - legacyToken := signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.IdentityID = identityID - c.IdentityAccessTokenID = tokenID.String() - c.OrgID = uuid.Nil - c.AuthMethod = "" - c.AccessTokenTTL = 0 - }) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), legacyToken, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "max TTL expired") -} - -func TestValidateIdentityAccessToken_LegacyUsageLimitReached(t *testing.T) { - nodejs := stack.NodeJS() - - projName := "test-legacy-usage-" + uuid.New().String()[:8] - proj := nodejs.CreateProject(t, projName) - t.Cleanup(func() { - nodejs.DeleteProject(t, proj.ID) - }) - - identity := nodejs.CreateIdentity(t, "legacy-usage-"+uuid.New().String()[:8]) - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - tokenID := uuid.New() - identityID := uuid.MustParse(identity.ID) - - // Insert a legacy token with usage limit reached - _, err := stack.DB().Primary().Exec(context.Background(), ` - INSERT INTO identity_access_tokens ( - id, "identityId", "isAccessTokenRevoked", "accessTokenTTL", "accessTokenMaxTTL", - "accessTokenNumUses", "accessTokenNumUsesLimit", "authMethod", "createdAt", "updatedAt" - ) VALUES ( - @tokenID, @identityID, false, 0, 0, 5, 5, - 'universal-auth', NOW(), NOW() - ) - `, pgx.NamedArgs{ - "tokenID": tokenID, - "identityID": identityID, - }) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = stack.DB().Primary().Exec(context.Background(), - `DELETE FROM identity_access_tokens WHERE id = @tokenID`, - pgx.NamedArgs{"tokenID": tokenID}) - }) - - legacyToken := signIdentityJWT(t, infra.AuthSecret, func(c *apiauth.IdentityJWTClaims) { - c.IdentityID = identityID - c.IdentityAccessTokenID = tokenID.String() - c.OrgID = uuid.Nil - c.AuthMethod = "" - c.AccessTokenTTL = 0 - }) - - _, err = authenticator.ValidateIdentityAccessToken(context.Background(), legacyToken, "") - - assertUnauthorized(t, err) - assert.Contains(t, err.Error(), "usage limit") -} diff --git a/backend-go/tests/platform/externalkms/externalkms_test.go b/backend-go/tests/platform/externalkms/externalkms_test.go deleted file mode 100644 index 7fd13fde248..00000000000 --- a/backend-go/tests/platform/externalkms/externalkms_test.go +++ /dev/null @@ -1,322 +0,0 @@ -//go:build integration - -package externalkms_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/ee/services/externalkms" - "github.com/infisical/api/tests/infra" -) - -// Secret keys in Infisical /platform folder. -const ( - // GCP - SecretGCPCredentialJSON = "EXTERNAL_KMS_GCP_CREDENTIAL_JSON" - SecretGCPKeyName = "EXTERNAL_KMS_GCP_KEY_NAME" - - // AWS Access Key auth - SecretAWSAccessKey = "EXTERNAL_KMS_AWS_ACCESS_KEY" - SecretAWSSecretKey = "EXTERNAL_KMS_AWS_SECRET_KEY" - SecretAWSRegion = "EXTERNAL_KMS_AWS_REGION" - SecretAWSKeyID = "EXTERNAL_KMS_AWS_KEY_ID" - - // AWS Assume Role auth - SecretAWSAssumeRoleARN = "EXTERNAL_KMS_AWS_ASSUME_ROLE_ARN" - SecretAWSAssumeRoleExternalID = "EXTERNAL_KMS_AWS_ASSUME_ROLE_EXTERNAL_ID" -) - -func newService(t *testing.T) *externalkms.Service { - t.Helper() - svc, err := externalkms.NewService(context.Background(), infra.NopLogger(), &externalkms.Deps{}) - require.NoError(t, err) - return svc -} - -// ========================================================================== -// GCP KMS -// ========================================================================== - -func TestGCPProvider_EncryptDecrypt_RoundTrip(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - credJSON := secrets.Require(t, SecretGCPCredentialJSON) - keyName := secrets.Require(t, SecretGCPKeyName) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalGCPConfig(t, credJSON, keyName) - plaintext := []byte("gcp-kms-test-payload") - - ciphertext, err := svc.Encrypt(ctx, externalkms.ProviderGCP, config, plaintext) - require.NoError(t, err) - require.NotEmpty(t, ciphertext) - require.NotEqual(t, plaintext, ciphertext, "ciphertext must differ from plaintext") - - decrypted, err := svc.Decrypt(ctx, externalkms.ProviderGCP, config, ciphertext) - require.NoError(t, err) - require.Equal(t, plaintext, decrypted) -} - -func TestGCPProvider_EncryptIsNonDeterministic(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - credJSON := secrets.Require(t, SecretGCPCredentialJSON) - keyName := secrets.Require(t, SecretGCPKeyName) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalGCPConfig(t, credJSON, keyName) - plaintext := []byte("same-input-different-output") - - ct1, err := svc.Encrypt(ctx, externalkms.ProviderGCP, config, plaintext) - require.NoError(t, err) - - ct2, err := svc.Encrypt(ctx, externalkms.ProviderGCP, config, plaintext) - require.NoError(t, err) - - require.NotEqual(t, ct1, ct2, "KMS encryption must use random IV, producing different ciphertext each time") -} - -func TestGCPProvider_DecryptInvalidCiphertext(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - credJSON := secrets.Require(t, SecretGCPCredentialJSON) - keyName := secrets.Require(t, SecretGCPKeyName) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalGCPConfig(t, credJSON, keyName) - garbage := []byte("this-is-not-valid-ciphertext") - - _, err := svc.Decrypt(ctx, externalkms.ProviderGCP, config, garbage) - require.Error(t, err, "decrypt must fail on invalid ciphertext") -} - -func mustMarshalGCPConfig(t *testing.T, credJSON, keyName string) []byte { - t.Helper() - - var cred externalkms.GcpCredential - err := json.Unmarshal([]byte(credJSON), &cred) - require.NoError(t, err, "GCP credential JSON must be valid") - - config := externalkms.GcpConfig{ - Credential: cred, - KeyName: keyName, - } - - data, err := json.Marshal(config) - require.NoError(t, err) - return data -} - -// ========================================================================== -// AWS KMS - Access Key Auth -// ========================================================================== - -func TestAWSProvider_AccessKey_EncryptDecrypt_RoundTrip(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - accessKey := secrets.Require(t, SecretAWSAccessKey) - secretKey := secrets.Require(t, SecretAWSSecretKey) - region := secrets.Require(t, SecretAWSRegion) - keyID := secrets.Require(t, SecretAWSKeyID) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalAWSAccessKeyConfig(t, accessKey, secretKey, region, keyID) - plaintext := []byte("aws-kms-accesskey-test-payload") - - ciphertext, err := svc.Encrypt(ctx, externalkms.ProviderAWS, config, plaintext) - require.NoError(t, err) - require.NotEmpty(t, ciphertext) - require.NotEqual(t, plaintext, ciphertext, "ciphertext must differ from plaintext") - - decrypted, err := svc.Decrypt(ctx, externalkms.ProviderAWS, config, ciphertext) - require.NoError(t, err) - require.Equal(t, plaintext, decrypted) -} - -func TestAWSProvider_AccessKey_EncryptIsNonDeterministic(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - accessKey := secrets.Require(t, SecretAWSAccessKey) - secretKey := secrets.Require(t, SecretAWSSecretKey) - region := secrets.Require(t, SecretAWSRegion) - keyID := secrets.Require(t, SecretAWSKeyID) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalAWSAccessKeyConfig(t, accessKey, secretKey, region, keyID) - plaintext := []byte("same-input-different-output") - - ct1, err := svc.Encrypt(ctx, externalkms.ProviderAWS, config, plaintext) - require.NoError(t, err) - - ct2, err := svc.Encrypt(ctx, externalkms.ProviderAWS, config, plaintext) - require.NoError(t, err) - - require.NotEqual(t, ct1, ct2, "KMS encryption must use random IV, producing different ciphertext each time") -} - -func TestAWSProvider_AccessKey_DecryptInvalidCiphertext(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - accessKey := secrets.Require(t, SecretAWSAccessKey) - secretKey := secrets.Require(t, SecretAWSSecretKey) - region := secrets.Require(t, SecretAWSRegion) - keyID := secrets.Require(t, SecretAWSKeyID) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalAWSAccessKeyConfig(t, accessKey, secretKey, region, keyID) - garbage := []byte("this-is-not-valid-ciphertext") - - _, err := svc.Decrypt(ctx, externalkms.ProviderAWS, config, garbage) - require.Error(t, err, "decrypt must fail on invalid ciphertext") -} - -func mustMarshalAWSAccessKeyConfig(t *testing.T, accessKey, secretKey, region, keyID string) []byte { - t.Helper() - - config := externalkms.AwsConfig{ - Credential: externalkms.AwsCredential{ - Type: externalkms.AwsCredentialTypeAccessKey, - Data: externalkms.AwsCredentialData{ - AccessKey: accessKey, - SecretKey: secretKey, - }, - }, - AwsRegion: region, - KmsKeyID: keyID, - } - - data, err := json.Marshal(config) - require.NoError(t, err) - return data -} - -// ========================================================================== -// AWS KMS - Assume Role Auth -// ========================================================================== - -func TestAWSProvider_AssumeRole_EncryptDecrypt_RoundTrip(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - accessKey := secrets.Require(t, SecretAWSAccessKey) - secretKey := secrets.Require(t, SecretAWSSecretKey) - roleARN := secrets.Require(t, SecretAWSAssumeRoleARN) - region := secrets.Require(t, SecretAWSRegion) - keyID := secrets.Require(t, SecretAWSKeyID) - externalID := secrets.Get(SecretAWSAssumeRoleExternalID) // optional - - // Set base credentials for STS AssumeRole - t.Setenv("AWS_ACCESS_KEY_ID", accessKey) - t.Setenv("AWS_SECRET_ACCESS_KEY", secretKey) - - svc := newService(t) - ctx := context.Background() - - config := mustMarshalAWSAssumeRoleConfig(t, roleARN, externalID, region, keyID) - plaintext := []byte("aws-kms-assumerole-test-payload") - - ciphertext, err := svc.Encrypt(ctx, externalkms.ProviderAWS, config, plaintext) - require.NoError(t, err) - require.NotEmpty(t, ciphertext) - require.NotEqual(t, plaintext, ciphertext, "ciphertext must differ from plaintext") - - decrypted, err := svc.Decrypt(ctx, externalkms.ProviderAWS, config, ciphertext) - require.NoError(t, err) - require.Equal(t, plaintext, decrypted) -} - -func mustMarshalAWSAssumeRoleConfig(t *testing.T, roleARN, externalID, region, keyID string) []byte { - t.Helper() - - config := externalkms.AwsConfig{ - Credential: externalkms.AwsCredential{ - Type: externalkms.AwsCredentialTypeAssumeRole, - Data: externalkms.AwsCredentialData{ - AssumeRoleArn: roleARN, - ExternalID: externalID, - }, - }, - AwsRegion: region, - KmsKeyID: keyID, - } - - data, err := json.Marshal(config) - require.NoError(t, err) - return data -} - -// ========================================================================== -// Cross-provider isolation -// ========================================================================== - -func TestCrossProvider_CiphertextNotInterchangeable(t *testing.T) { - secrets := infra.GetPlatformSecrets(t) - - // Skip if either provider's secrets are missing - gcpCredJSON := secrets.Require(t, SecretGCPCredentialJSON) - gcpKeyName := secrets.Require(t, SecretGCPKeyName) - awsAccessKey := secrets.Require(t, SecretAWSAccessKey) - awsSecretKey := secrets.Require(t, SecretAWSSecretKey) - awsRegion := secrets.Require(t, SecretAWSRegion) - awsKeyID := secrets.Require(t, SecretAWSKeyID) - - svc := newService(t) - ctx := context.Background() - - gcpConfig := mustMarshalGCPConfig(t, gcpCredJSON, gcpKeyName) - awsConfig := mustMarshalAWSAccessKeyConfig(t, awsAccessKey, awsSecretKey, awsRegion, awsKeyID) - - plaintext := []byte("cross-provider-test") - - // Encrypt with GCP - gcpCiphertext, err := svc.Encrypt(ctx, externalkms.ProviderGCP, gcpConfig, plaintext) - require.NoError(t, err) - - // Attempt to decrypt GCP ciphertext with AWS — must fail - _, err = svc.Decrypt(ctx, externalkms.ProviderAWS, awsConfig, gcpCiphertext) - require.Error(t, err, "GCP ciphertext must not be decryptable by AWS KMS") - - // Encrypt with AWS - awsCiphertext, err := svc.Encrypt(ctx, externalkms.ProviderAWS, awsConfig, plaintext) - require.NoError(t, err) - - // Attempt to decrypt AWS ciphertext with GCP — must fail - _, err = svc.Decrypt(ctx, externalkms.ProviderGCP, gcpConfig, awsCiphertext) - require.Error(t, err, "AWS ciphertext must not be decryptable by GCP KMS") -} - -// ========================================================================== -// Error cases -// ========================================================================== - -func TestService_UnsupportedProvider(t *testing.T) { - svc := newService(t) - ctx := context.Background() - - _, err := svc.Encrypt(ctx, "unsupported-provider", []byte("{}"), []byte("test")) - require.Error(t, err) - require.Contains(t, err.Error(), "unsupported") -} - -func TestService_InvalidConfigJSON(t *testing.T) { - svc := newService(t) - ctx := context.Background() - - invalidJSON := []byte("not-valid-json") - - _, err := svc.Encrypt(ctx, externalkms.ProviderAWS, invalidJSON, []byte("test")) - require.Error(t, err) - require.Contains(t, err.Error(), "parsing") - - _, err = svc.Encrypt(ctx, externalkms.ProviderGCP, invalidJSON, []byte("test")) - require.Error(t, err) - require.Contains(t, err.Error(), "parsing") -} diff --git a/backend-go/tests/platform/hsm/hsm_test.go b/backend-go/tests/platform/hsm/hsm_test.go deleted file mode 100644 index f2ff65cfd95..00000000000 --- a/backend-go/tests/platform/hsm/hsm_test.go +++ /dev/null @@ -1,192 +0,0 @@ -//go:build integration - -package hsm_test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/ee/services/hsm" - "github.com/infisical/api/tests/infra" -) - -var runner = infra.NewContainerTest( - "infisical/go-test-backend", - "INFISICAL_HSM_TEST_INSIDE", - "INFISICAL_RUN_HSM_CONTAINER_TEST", -). - WithTestPath("./tests/platform/hsm/..."). - WithBuildTags("integration"). - WithEnv("SOFTHSM2_LIB", "/usr/lib/softhsm/libsofthsm2.so") - -func TestMain(m *testing.M) { - if runner.IsInsideContainer() { - os.Exit(m.Run()) - } - - os.Exit(runner.MustRun()) -} - -// ============================================================================ -// HSM tests (run inside the container) -// ============================================================================ - -func setupHSM(t *testing.T) *hsm.Service { - t.Helper() - - if !runner.IsInsideContainer() { - t.Skip("skipping: not inside test container") - } - - libPath := os.Getenv("SOFTHSM2_LIB") - require.NotEmpty(t, libPath, "SOFTHSM2_LIB not set") - - tokenDir := t.TempDir() - confPath := filepath.Join(tokenDir, "softhsm2.conf") - require.NoError(t, os.WriteFile(confPath, []byte( - "directories.tokendir = "+tokenDir+"\nobjectstore.backend = file\n", - ), 0o644)) - t.Setenv("SOFTHSM2_CONF", confPath) - - cmd := exec.CommandContext(t.Context(), "softhsm2-util", - "--init-token", "--free", - "--label", "test-token", - "--pin", "1234", "--so-pin", "5678", - ) - cmd.Env = append(os.Environ(), "SOFTHSM2_CONF="+confPath) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "softhsm2-util: "+string(out)) - - svc, err := hsm.NewService(hsm.Config{ - LibPath: libPath, - TokenLabel: "test-token", - Pin: "1234", - KeyLabel: "test-infisical", - }) - require.NoError(t, err) - t.Cleanup(func() { svc.Close() }) - - require.NoError(t, svc.StartService(true)) - - return svc -} - -func TestStartService_IsActive(t *testing.T) { - svc := setupHSM(t) - assert.True(t, svc.IsActive()) -} - -func TestEncrypt_RoundTrip(t *testing.T) { - svc := setupHSM(t) - - plaintext := []byte("hello from the HSM") - - blob, err := svc.Encrypt(plaintext) - require.NoError(t, err) - assert.GreaterOrEqual(t, len(blob), 16+16+32) - - got, err := svc.Decrypt(blob) - require.NoError(t, err) - assert.Equal(t, plaintext, got) -} - -func TestEncrypt_EmptyData(t *testing.T) { - svc := setupHSM(t) - - blob, err := svc.Encrypt([]byte{}) - require.NoError(t, err) - - got, err := svc.Decrypt(blob) - require.NoError(t, err) - assert.Equal(t, []byte{}, got) -} - -func TestEncrypt_LargeData(t *testing.T) { - svc := setupHSM(t) - - plaintext := make([]byte, 500) - for i := range plaintext { - plaintext[i] = byte(i % 256) - } - - blob, err := svc.Encrypt(plaintext) - require.NoError(t, err) - - got, err := svc.Decrypt(blob) - require.NoError(t, err) - assert.Equal(t, plaintext, got) -} - -func TestDecrypt_TamperedHMAC(t *testing.T) { - svc := setupHSM(t) - - blob, err := svc.Encrypt([]byte("tamper test")) - require.NoError(t, err) - - blob[len(blob)-1] ^= 0xFF - - _, err = svc.Decrypt(blob) - assert.Error(t, err) -} - -func TestDecrypt_TamperedCiphertext(t *testing.T) { - svc := setupHSM(t) - - blob, err := svc.Encrypt([]byte("tamper ciphertext")) - require.NoError(t, err) - - blob[20] ^= 0xFF - - _, err = svc.Decrypt(blob) - assert.Error(t, err) -} - -func TestDecrypt_TamperedIV(t *testing.T) { - svc := setupHSM(t) - - blob, err := svc.Encrypt([]byte("tamper iv")) - require.NoError(t, err) - - blob[0] ^= 0xFF - - _, err = svc.Decrypt(blob) - assert.Error(t, err) -} - -func TestDecrypt_TooShort(t *testing.T) { - svc := setupHSM(t) - - _, err := svc.Decrypt([]byte("short")) - assert.Error(t, err) -} - -func TestRandomBytes_ReturnsUniqueBytes(t *testing.T) { - svc := setupHSM(t) - - a, err := svc.RandomBytes(32) - require.NoError(t, err) - assert.Len(t, a, 32) - - b, err := svc.RandomBytes(32) - require.NoError(t, err) - assert.Len(t, b, 32) - - assert.NotEqual(t, a, b) -} - -func TestEncrypt_ProducesDifferentBlobs(t *testing.T) { - svc := setupHSM(t) - - blob1, err := svc.Encrypt([]byte("same")) - require.NoError(t, err) - - blob2, err := svc.Encrypt([]byte("same")) - require.NoError(t, err) - - assert.NotEqual(t, blob1, blob2, "two encryptions of same data should differ due to random IV") -} diff --git a/backend-go/tests/platform/kms/kms_test.go b/backend-go/tests/platform/kms/kms_test.go deleted file mode 100644 index df688f406b5..00000000000 --- a/backend-go/tests/platform/kms/kms_test.go +++ /dev/null @@ -1,422 +0,0 @@ -//go:build integration - -package kms_test - -import ( - "context" - "encoding/json" - "os" - "sync" - "testing" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/ee/services/externalkms" - "github.com/infisical/api/internal/services/kms" - "github.com/infisical/api/tests/infra" -) - -var stack *infra.Stack - -func TestMain(m *testing.M) { - stack = infra.New(). - WithPostgres(). - WithRedis(). - WithNodeJSApi(). - MustStart() - - code := m.Run() - stack.Stop() - os.Exit(code) -} - -func startedService(t *testing.T) *kms.Service { - t.Helper() - svc, err := kms.NewService(context.Background(), infra.NopLogger(), &kms.Deps{ - DB: stack.DB(), - HSM: nil, - ExternalKms: nil, - Config: stack.Config(), - }) - require.NoError(t, err) - require.NoError(t, svc.Start(context.Background(), false)) - return svc -} - -// ========================================================================== -// Key Isolation: Project A cannot decrypt Project B's data -// ========================================================================== - -func TestProjectIsolation_CrossProjectDecryptFails(t *testing.T) { - svc := startedService(t) - ctx := context.Background() - - proj1 := stack.NodeJS().MustCreateProject("kms-iso-proj1") - proj2 := stack.NodeJS().MustCreateProject("kms-iso-proj2") - - pair1, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj1.ID) - require.NoError(t, err) - - pair2, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj2.ID) - require.NoError(t, err) - - secret := []byte("project1 secret") - ciphertext, err := pair1.Encrypt(secret) - require.NoError(t, err) - - // Project2 must NOT decrypt project1's data - _, err = pair2.Decrypt(ciphertext) - require.Error(t, err, "project2 should not decrypt project1's ciphertext") -} - -// ========================================================================== -// Concurrency: Racing first-access creates exactly ONE KMS key -// ========================================================================== - -func TestConcurrentFirstAccess_CreatesExactlyOneProjectKey(t *testing.T) { - svc := startedService(t) - ctx := context.Background() - - // Fresh project with no KMS key yet - proj := stack.NodeJS().MustCreateProject("kms-race-proj") - - const n = 10 - var wg sync.WaitGroup - wg.Add(n) - errs := make([]error, n) - - for i := 0; i < n; i++ { - go func(idx int) { - defer wg.Done() - _, errs[idx] = svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - }(i) - } - wg.Wait() - - for i, err := range errs { - require.NoError(t, err, "goroutine %d failed", i) - } - - // Verify: exactly ONE kms_keys row for this project - var kmsKeyCount int - err := stack.DB().Replica().QueryRow(ctx, ` - SELECT COUNT(*) FROM kms_keys k - JOIN projects p ON p."kmsSecretManagerKeyId" = k.id - WHERE p.id = @projectID - `, pgx.NamedArgs{"projectID": proj.ID}).Scan(&kmsKeyCount) - require.NoError(t, err) - require.Equal(t, 1, kmsKeyCount, "expected exactly 1 KMS key, got %d", kmsKeyCount) -} - -// ========================================================================== -// Node.js ↔ Go Compatibility (using secrets as proxy) -// ========================================================================== - -func TestCompatibility_GoDecryptsNodeEncryptedSecret(t *testing.T) { - ctx := context.Background() - - // Create project and secret via Node.js - proj := stack.NodeJS().MustCreateProject("compat-node-to-go") - plaintext := "node-encrypted-secret-value" - secret := stack.NodeJS().CreateSecret(t, proj.ID, "dev", "/", "NODE_SECRET", plaintext, nil) - - // Read encrypted value directly from secrets_v2 - var encryptedValue []byte - err := stack.DB().Replica().QueryRow(ctx, ` - SELECT "encryptedValue" - FROM secrets_v2 - WHERE id = @secretID - `, pgx.NamedArgs{"secretID": secret.ID}).Scan(&encryptedValue) - require.NoError(t, err) - require.NotEmpty(t, encryptedValue) - - // Go decrypts using project's cipher pair - svc := startedService(t) - pair, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.NoError(t, err) - - decrypted, err := pair.Decrypt(encryptedValue) - require.NoError(t, err) - require.Equal(t, plaintext, string(decrypted)) -} - -func TestCompatibility_NodeDecryptsGoEncryptedSecret(t *testing.T) { - ctx := context.Background() - - // Create project and initial secret via Node.js - proj := stack.NodeJS().MustCreateProject("compat-go-to-node") - originalValue := "original-value" - stack.NodeJS().CreateSecret(t, proj.ID, "dev", "/", "GO_SECRET", originalValue, nil) - - // Go encrypts a new value - svc := startedService(t) - pair, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.NoError(t, err) - - newPlaintext := "go-encrypted-secret-value" - newEncrypted, err := pair.Encrypt([]byte(newPlaintext)) - require.NoError(t, err) - - // Update the secret's encrypted value directly in secrets_v2 - result, err := stack.DB().Primary().Exec(ctx, ` - UPDATE secrets_v2 - SET "encryptedValue" = @encryptedValue - WHERE key = 'GO_SECRET' AND "folderId" IN ( - SELECT id FROM secret_folders WHERE "envId" IN ( - SELECT id FROM project_environments WHERE "projectId" = @projectID AND slug = 'dev' - ) AND name = 'root' - ) - `, pgx.NamedArgs{ - "encryptedValue": newEncrypted, - "projectID": proj.ID, - }) - require.NoError(t, err) - require.Equal(t, int64(1), result.RowsAffected(), "should update exactly 1 row") - - // Node.js reads the secret via API - should decrypt successfully - readSecret := stack.NodeJS().GetSecretByKey(t, proj.ID, "dev", "/", "GO_SECRET") - require.Equal(t, newPlaintext, readSecret.Value) -} - -// ========================================================================== -// Corrupted Input Handling -// ========================================================================== - -func TestCorruptedCiphertext_TruncatedFails(t *testing.T) { - svc := startedService(t) - ctx := context.Background() - - proj := stack.NodeJS().MustCreateProject("kms-corrupt-trunc") - - pair, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.NoError(t, err) - - ciphertext, err := pair.Encrypt([]byte("some data")) - require.NoError(t, err) - - // Truncate ciphertext - truncated := ciphertext[:len(ciphertext)/2] - _, err = pair.Decrypt(truncated) - require.Error(t, err, "truncated ciphertext should fail") -} - -func TestCorruptedCiphertext_BitFlipFails(t *testing.T) { - svc := startedService(t) - ctx := context.Background() - - proj := stack.NodeJS().MustCreateProject("kms-corrupt-flip") - - pair, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.NoError(t, err) - - ciphertext, err := pair.Encrypt([]byte("authentic data")) - require.NoError(t, err) - - // Flip a bit in the middle of ciphertext - corrupted := make([]byte, len(ciphertext)) - copy(corrupted, ciphertext) - corrupted[len(corrupted)/2] ^= 0xFF - - _, err = pair.Decrypt(corrupted) - require.Error(t, err, "corrupted ciphertext should fail authentication") -} - -func TestCorruptedCiphertext_WrongVersionSuffixStillDecrypts(t *testing.T) { - // Version suffix is informational metadata, not part of authentication. - // Modifying it does not break decryption - the ciphertext body is still valid. - svc := startedService(t) - ctx := context.Background() - - proj := stack.NodeJS().MustCreateProject("kms-corrupt-ver") - - pair, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.NoError(t, err) - - plaintext := []byte("versioned data") - ciphertext, err := pair.Encrypt(plaintext) - require.NoError(t, err) - - // Replace version suffix (last 3 bytes) - decryption still works - tampered := make([]byte, len(ciphertext)) - copy(tampered, ciphertext) - copy(tampered[len(tampered)-3:], "v99") - - decrypted, err := pair.Decrypt(tampered) - require.NoError(t, err, "version suffix is not validated") - require.Equal(t, plaintext, decrypted) -} - -// ========================================================================== -// Error Cases -// ========================================================================== - -func TestCreateCipherPair_NonexistentProjectFails(t *testing.T) { - svc := startedService(t) - ctx := context.Background() - - _, err := svc.CreateCipherPairWithProjectDataKey(ctx, "nonexistent-project-id") - require.Error(t, err) -} - -func TestCreateCipherPair_EmptyProjectIDFails(t *testing.T) { - svc := startedService(t) - ctx := context.Background() - - _, err := svc.CreateCipherPairWithProjectDataKey(ctx, "") - require.Error(t, err) - require.Contains(t, err.Error(), "project ID is required") -} - -// ========================================================================== -// External KMS Tests (using mock provider) -// ========================================================================== - -func startedServiceWithExternalKms(t *testing.T, extKms kms.ExternalKmsService) *kms.Service { - t.Helper() - svc, err := kms.NewService(context.Background(), infra.NopLogger(), &kms.Deps{ - DB: stack.DB(), - HSM: nil, - ExternalKms: extKms, - Config: stack.Config(), - }) - require.NoError(t, err) - require.NoError(t, svc.Start(context.Background(), false)) - return svc -} - -// setupExternalKmsForProject creates an external KMS key for a project. -// Returns the project and the original (unencrypted) provider config. -func setupExternalKmsForProject(t *testing.T, svc *kms.Service, mock *externalkms.MockProvider, projectName string) (proj *infra.ProjectSeed, configJSON []byte) { - t.Helper() - ctx := context.Background() - - // Create project via Node.js (this sets up org with internal KMS) - proj = stack.NodeJS().MustCreateProject(projectName) - - // Get org ID from project - var orgID uuid.UUID - err := stack.DB().Replica().QueryRow(ctx, `SELECT "orgId" FROM projects WHERE id = @id`, - pgx.NamedArgs{"id": proj.ID}).Scan(&orgID) - require.NoError(t, err) - - // First, ensure org has a data key by creating a cipher pair - orgPair, err := svc.CreateCipherPairWithOrgDataKey(ctx, orgID) - require.NoError(t, err) - - // Create fake external KMS config - providerConfig := map[string]string{"key": "test-key-id", "region": "us-east-1"} - configJSON, err = json.Marshal(providerConfig) - require.NoError(t, err) - - // Encrypt config with org's data key - encryptedConfig, err := orgPair.Encrypt(configJSON) - require.NoError(t, err) - - // Create external KMS key in database - var kmsKeyID uuid.UUID - err = stack.DB().Primary().QueryRow(ctx, ` - INSERT INTO kms_keys (name, "orgId", "isReserved", "keyUsage") - VALUES (@name, @orgID, false, 'encrypt-decrypt') - RETURNING id - `, pgx.NamedArgs{"name": "test-external-kms", "orgID": orgID}).Scan(&kmsKeyID) - require.NoError(t, err) - - // Create external_kms record - _, err = stack.DB().Primary().Exec(ctx, ` - INSERT INTO external_kms (id, provider, "encryptedProviderInputs", "kmsKeyId") - VALUES (gen_random_uuid(), 'aws', @encryptedConfig, @kmsKeyID) - `, pgx.NamedArgs{"encryptedConfig": encryptedConfig, "kmsKeyID": kmsKeyID}) - require.NoError(t, err) - - // Generate project data key and "encrypt" it with mock external KMS - // Mock uses XOR with 0x42, so we simulate what the external KMS would return - plainDataKey := make([]byte, 32) - for i := range plainDataKey { - plainDataKey[i] = byte(i + 1) // deterministic for testing - } - encryptedDataKey, err := mock.Encrypt(ctx, "aws", configJSON, plainDataKey) - require.NoError(t, err) - - // Update project to use external KMS and set the encrypted data key - _, err = stack.DB().Primary().Exec(ctx, ` - UPDATE projects - SET "kmsSecretManagerKeyId" = @kmsKeyID, - "kmsSecretManagerEncryptedDataKey" = @encryptedDataKey - WHERE id = @projectID - `, pgx.NamedArgs{"kmsKeyID": kmsKeyID, "encryptedDataKey": encryptedDataKey, "projectID": proj.ID}) - require.NoError(t, err) - - // Reset mock counters after setup - mock.Reset() - - return proj, configJSON -} - -func TestExternalKms_DecryptFlowCallsProvider(t *testing.T) { - ctx := context.Background() - mock := externalkms.NewMockProvider() - svc := startedServiceWithExternalKms(t, mock) - - proj, expectedConfig := setupExternalKmsForProject(t, svc, mock, "ext-kms-decrypt") - - // Create cipher pair - this calls external KMS to decrypt the project data key - pair, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.NoError(t, err) - - // External KMS Decrypt was called once to get the project data key - require.Equal(t, 1, mock.DecryptCalls, "external KMS decrypt should be called to get data key") - require.Equal(t, 0, mock.EncryptCalls, "external KMS encrypt is not used in this flow") - - // Verify the config was decrypted and passed to provider - require.JSONEq(t, string(expectedConfig), string(mock.LastConfig)) - require.Equal(t, externalkms.ProviderAWS, mock.LastProvider) - - // Local encrypt/decrypt uses the decrypted data key (no more external KMS calls) - plaintext := []byte("external kms test data") - ciphertext, err := pair.Encrypt(plaintext) - require.NoError(t, err) - - decrypted, err := pair.Decrypt(ciphertext) - require.NoError(t, err) - require.Equal(t, plaintext, decrypted) - - // No additional external KMS calls - local crypto uses the data key - require.Equal(t, 1, mock.DecryptCalls, "no additional decrypt calls") - require.Equal(t, 0, mock.EncryptCalls, "no encrypt calls") -} - -func TestExternalKms_ProviderFailurePropagates(t *testing.T) { - ctx := context.Background() - mock := externalkms.NewMockProvider() - svc := startedServiceWithExternalKms(t, mock) - - proj, _ := setupExternalKmsForProject(t, svc, mock, "ext-kms-fail") - - // Now configure mock to fail - mock.ShouldFail = true - - // CreateCipherPairWithProjectDataKey should fail because it tries to decrypt - // the data key using the external KMS, which is configured to fail - _, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.Error(t, err) - require.Contains(t, err.Error(), "mock external KMS decrypt failed") -} - -func TestExternalKms_NilServiceReturnsError(t *testing.T) { - ctx := context.Background() - mock := externalkms.NewMockProvider() - - // First create with mock to set up the external KMS in DB - svcWithMock := startedServiceWithExternalKms(t, mock) - proj, _ := setupExternalKmsForProject(t, svcWithMock, mock, "ext-kms-nil") - - // Now create service WITHOUT external KMS - svc := startedService(t) - - // This should fail because external KMS service is nil - _, err := svc.CreateCipherPairWithProjectDataKey(ctx, proj.ID) - require.Error(t, err) - require.Contains(t, err.Error(), "external KMS service not configured") -} diff --git a/backend-go/tests/platform/permission/permission_test.go b/backend-go/tests/platform/permission/permission_test.go deleted file mode 100644 index 39c0e3e914a..00000000000 --- a/backend-go/tests/platform/permission/permission_test.go +++ /dev/null @@ -1,618 +0,0 @@ -//go:build integration - -package permission_test - -import ( - "context" - "os" - "testing" - "time" - - "github.com/google/uuid" - "github.com/infisical/gocasl" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/internal/services/permission" - "github.com/infisical/api/internal/services/permission/project" - "github.com/infisical/api/tests/infra" -) - -var ( - stack *infra.Stack - proj *infra.ProjectSeed -) - -func TestMain(m *testing.M) { - stack = infra.New(). - WithPostgres(). - WithRedis(). - WithNodeJSApi(). - WithEEFeatures("rbac", "groups"). - MustStart() - - proj = stack.NodeJS().MustCreateProject("perm-test") - code := m.Run() - stack.Stop() - os.Exit(code) -} - -func newPermissionService() *permission.Service { - return permission.NewService(context.Background(), infra.NopLogger(), &permission.Deps{DB: stack.DB()}) -} - -func getProjectPermission(t *testing.T, actorType auth.ActorType, actorID string) *permission.GetProjectPermissionResult { - t.Helper() - ctx := context.Background() - svc := newPermissionService() - - result, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: actorType, - ActorID: uuid.MustParse(actorID), - ProjectID: proj.ID, - ActorAuthMethod: "", - ActorOrgID: uuid.MustParse(stack.NodeJS().OrgID()), - ActionProjectType: permission.ActionProjectTypeSecretManager, - }) - require.NoError(t, err) - require.NotNil(t, result) - return result -} - -// =========================== -// Identity role tests -// =========================== - -func TestIdentityAdmin_CanDoEverything(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "admin-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionCreate, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionEdit, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionDelete, project.SecretFolderSubject{})) - - assert.True(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionCreate, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionEdit, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionDelete, project.MemberSubject{})) - - assert.True(t, gocasl.Can(ability, project.ProjectActionEdit, project.ProjectSubject{})) - assert.True(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - - assert.True(t, gocasl.Can(ability, project.RoleActionRead, project.RoleSubject{})) - assert.True(t, gocasl.Can(ability, project.RoleActionCreate, project.RoleSubject{})) - - assert.True(t, gocasl.Can(ability, project.IdentityActionRead, project.IdentitySubject{})) - assert.True(t, gocasl.Can(ability, project.IdentityActionCreate, project.IdentitySubject{})) - assert.True(t, gocasl.Can(ability, project.IdentityActionGrantPrivileges, project.IdentitySubject{})) - - assert.True(t, gocasl.Can(ability, project.EnvironmentsActionRead, project.EnvironmentsSubject{})) - assert.True(t, gocasl.Can(ability, project.EnvironmentsActionCreate, project.EnvironmentsSubject{})) - - assert.True(t, result.HasRole("admin")) - assert.False(t, result.HasRole("viewer")) -} - -func TestIdentityMember_LimitedAccess(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "member-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("member")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionCreate, project.SecretFolderSubject{})) - - assert.False(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - - assert.False(t, gocasl.Can(ability, project.MemberActionGrantPrivileges, project.MemberSubject{})) - assert.False(t, gocasl.Can(ability, project.IdentityActionGrantPrivileges, project.IdentitySubject{})) - - assert.True(t, result.HasRole("member")) - assert.False(t, result.HasRole("admin")) -} - -func TestIdentityViewer_ReadOnly(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "viewer-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("viewer")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeSecret, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.IdentityActionRead, project.IdentitySubject{})) - assert.True(t, gocasl.Can(ability, project.EnvironmentsActionRead, project.EnvironmentsSubject{})) - assert.True(t, gocasl.Can(ability, project.TagsActionRead, project.TagsSubject{})) - assert.True(t, gocasl.Can(ability, project.RoleActionRead, project.RoleSubject{})) - assert.True(t, gocasl.Can(ability, project.AuditLogsActionRead, project.AuditLogsSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretFolderActionCreate, project.SecretFolderSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretFolderActionEdit, project.SecretFolderSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretFolderActionDelete, project.SecretFolderSubject{})) - - assert.False(t, gocasl.Can(ability, project.MemberActionCreate, project.MemberSubject{})) - assert.False(t, gocasl.Can(ability, project.MemberActionDelete, project.MemberSubject{})) - - assert.False(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - - assert.False(t, gocasl.Can(ability, project.DynamicSecretActionCreateRootCredential, project.DynamicSecretSubject{})) - - assert.True(t, result.HasRole("viewer")) -} - -func TestIdentityNoAccess_DeniedEverything(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "no-access-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("no-access")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.False(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - assert.False(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - assert.False(t, gocasl.Can(ability, project.IdentityActionRead, project.IdentitySubject{})) - - assert.True(t, result.HasRole("no-access")) -} - -func TestIdentityNotMember_Forbidden(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "outsider-identity") - - ctx := context.Background() - svc := newPermissionService() - - _, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: auth.ActorTypeIdentity, - ActorID: uuid.MustParse(identity.ID), - ProjectID: proj.ID, - ActorAuthMethod: "", - ActorOrgID: uuid.MustParse(nodejs.OrgID()), - ActionProjectType: permission.ActionProjectTypeSecretManager, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "not a member") -} - -// =========================== -// User role tests -// =========================== - -func TestUserAdmin_CanDoEverything(t *testing.T) { - nodejs := stack.NodeJS() - user := nodejs.InviteAndCreateUser(t, "admin-user@test.local") - nodejs.AddUserToProject(t, proj.ID, user.Email, []string{"admin"}) - - result := getProjectPermission(t, auth.ActorTypeUser, user.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionCreate, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - assert.True(t, gocasl.Can(ability, project.IdentityActionGrantPrivileges, project.IdentitySubject{})) - - assert.True(t, result.HasRole("admin")) -} - -func TestUserViewer_ReadOnly(t *testing.T) { - nodejs := stack.NodeJS() - user := nodejs.InviteAndCreateUser(t, "viewer-user@test.local") - nodejs.AddUserToProject(t, proj.ID, user.Email, []string{"viewer"}) - - result := getProjectPermission(t, auth.ActorTypeUser, user.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeSecret, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - - assert.True(t, result.HasRole("viewer")) -} - -// =========================== -// Wrong org / wrong project type -// =========================== - -func TestWrongOrgID_Forbidden(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "wrong-org-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - ctx := context.Background() - svc := newPermissionService() - - fakeOrgID := uuid.New() - _, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: auth.ActorTypeIdentity, - ActorID: uuid.MustParse(identity.ID), - ProjectID: proj.ID, - ActorAuthMethod: "", - ActorOrgID: fakeOrgID, - ActionProjectType: permission.ActionProjectTypeSecretManager, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "does not belong") -} - -func TestWrongProjectType_BadRequest(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "wrong-type-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - ctx := context.Background() - svc := newPermissionService() - - _, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: auth.ActorTypeIdentity, - ActorID: uuid.MustParse(identity.ID), - ProjectID: proj.ID, - ActorAuthMethod: "", - ActorOrgID: uuid.MustParse(nodejs.OrgID()), - ActionProjectType: permission.ActionProjectTypeCertificateManager, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "not allowed") -} - -func TestProjectTypeAny_Allowed(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "any-type-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("viewer")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ctx := context.Background() - svc := newPermissionService() - resultAny, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: auth.ActorTypeIdentity, - ActorID: uuid.MustParse(identity.ID), - ProjectID: proj.ID, - ActorAuthMethod: "", - ActorOrgID: uuid.MustParse(nodejs.OrgID()), - ActionProjectType: permission.ActionProjectTypeAny, - }) - require.NoError(t, err) - require.NotNil(t, resultAny) - - assert.True(t, gocasl.Can(result.Permission.Ability, project.SecretActionDescribeSecret, project.SecretSubject{})) - assert.True(t, gocasl.Can(resultAny.Permission.Ability, project.SecretActionDescribeSecret, project.SecretSubject{})) -} - -// =========================== -// Non-existent project -// =========================== - -func TestNonExistentProject_NotFound(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "ghost-project-identity") - - ctx := context.Background() - svc := newPermissionService() - - _, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: auth.ActorTypeIdentity, - ActorID: uuid.MustParse(identity.ID), - ProjectID: "non-existent-project-id", - ActorAuthMethod: "", - ActorOrgID: uuid.MustParse(nodejs.OrgID()), - ActionProjectType: permission.ActionProjectTypeSecretManager, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -// =========================== -// Membership results -// =========================== - -func TestMemberships_ReturnedCorrectly(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "membership-check-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("member")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - - require.NotEmpty(t, result.Memberships, "memberships should not be empty") - assert.True(t, result.HasRole("member")) - assert.False(t, result.HasRole("admin")) - assert.False(t, result.HasRole("viewer")) -} - -// =========================== -// Project enforcement -// =========================== - -func TestHasProjectEnforcement_ReturnsFunction(t *testing.T) { - nodejs := stack.NodeJS() - identity := nodejs.CreateIdentity(t, "enforcement-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("viewer")) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - - require.NotNil(t, result.HasProjectEnforcement) - assert.False(t, result.HasProjectEnforcement("enforceEncryptedSecretManagerSecretMetadata")) - assert.False(t, result.HasProjectEnforcement("nonExistentEnforcement")) -} - -// =========================== -// Custom role tests -// =========================== - -func TestIdentityCustomRole_SecretsReadCreateOnly(t *testing.T) { - nodejs := stack.NodeJS() - - customRole := nodejs.CreateCustomProjectRole(t, proj.ID, "secrets-read-create", "Secrets Read Create", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read", "create"}, - }, - }) - - identity := nodejs.CreateIdentity(t, "custom-role-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role(customRole.Slug)) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.False(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - assert.False(t, gocasl.Can(ability, project.ProjectActionEdit, project.ProjectSubject{})) - assert.False(t, gocasl.Can(ability, project.IdentityActionRead, project.IdentitySubject{})) - - assert.True(t, result.HasRole(customRole.Slug)) - assert.False(t, result.HasRole("admin")) - assert.False(t, result.HasRole("member")) -} - -func TestIdentityCustomRole_EnvironmentScoped(t *testing.T) { - nodejs := stack.NodeJS() - - customRole := nodejs.CreateCustomProjectRole(t, proj.ID, "dev-reader", "Dev Secret Reader", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read"}, - Conditions: map[string]any{ - "environment": "dev", - }, - }, - }) - - identity := nodejs.CreateIdentity(t, "env-scoped-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role(customRole.Slug)) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{Environment: "dev"})) - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{Environment: "production"})) - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{Environment: "staging"})) - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{Environment: "dev"})) - assert.False(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{Environment: "dev"})) - assert.False(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{Environment: "dev"})) -} - -func TestIdentityCustomRole_GlobSecretPath(t *testing.T) { - nodejs := stack.NodeJS() - - customRole := nodejs.CreateCustomProjectRole(t, proj.ID, "path-reader", "Path Scoped Reader", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read"}, - Conditions: map[string]any{ - "secretPath": map[string]any{ - "$glob": "/app/**", - }, - }, - }, - }) - - identity := nodejs.CreateIdentity(t, "glob-path-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role(customRole.Slug)) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{SecretPath: "/app/config"})) - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{SecretPath: "/app/nested/deep"})) - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{SecretPath: "/other/path"})) - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{SecretPath: "/"})) - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) -} - -// =========================== -// Group role tests -// =========================== - -func TestGroupAdmin_UserInheritsFullAccess(t *testing.T) { - nodejs := stack.NodeJS() - - group := nodejs.CreateGroup(t, "admin-group") - user := nodejs.InviteAndCreateUser(t, "group-admin@test.local") - nodejs.AddUserToGroup(t, group.ID, user.Email) - nodejs.AddGroupToProject(t, proj.ID, group.ID, "admin") - - result := getProjectPermission(t, auth.ActorTypeUser, user.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionCreate, project.MemberSubject{})) - assert.True(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - assert.True(t, gocasl.Can(ability, project.IdentityActionGrantPrivileges, project.IdentitySubject{})) -} - -func TestGroupViewer_UserInheritsReadOnly(t *testing.T) { - nodejs := stack.NodeJS() - - group := nodejs.CreateGroup(t, "viewer-group") - user := nodejs.InviteAndCreateUser(t, "group-viewer@test.local") - nodejs.AddUserToGroup(t, group.ID, user.Email) - nodejs.AddGroupToProject(t, proj.ID, group.ID, "viewer") - - result := getProjectPermission(t, auth.ActorTypeUser, user.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeSecret, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - assert.True(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) -} - -// =========================== -// Additional privilege tests -// =========================== - -func TestIdentityAdditionalPrivilege_ExtendsRole(t *testing.T) { - nodejs := stack.NodeJS() - - identity := nodejs.CreateIdentity(t, "addl-priv-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("viewer")) - nodejs.CreateIdentityAdditionalPrivilege(t, identity.ID, proj.ID, []infra.Permission{ - { - Subject: "secrets", - Action: "create", - }, - }, nil) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeSecret, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretFolderActionRead, project.SecretFolderSubject{})) - - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - - assert.False(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) -} - -// =========================== -// Temporary role tests -// =========================== - -func TestIdentityTemporaryRole_ActiveGrantsAccess(t *testing.T) { - nodejs := stack.NodeJS() - - identity := nodejs.CreateIdentity(t, "temp-role-active-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, []infra.RoleAssignment{ - { - Role: "no-access", - IsTemporary: false, - }, - { - Role: "admin", - IsTemporary: true, - TemporaryMode: "relative", - TemporaryRange: "1h", - TemporaryAccessStartTime: time.Now().UTC().Format(time.RFC3339), - }, - }) - - result := getProjectPermission(t, auth.ActorTypeIdentity, identity.ID) - ability := result.Permission.Ability - - assert.True(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionEdit, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.SecretActionDelete, project.SecretSubject{})) - assert.True(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) -} - -func TestIdentityTemporaryRole_ExpiredDeniesAccess(t *testing.T) { - nodejs := stack.NodeJS() - - identity := nodejs.CreateIdentity(t, "temp-role-expired-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, []infra.RoleAssignment{ - { - Role: "no-access", - IsTemporary: false, - }, - { - Role: "admin", - IsTemporary: true, - TemporaryMode: "relative", - TemporaryRange: "1h", - TemporaryAccessStartTime: time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339), - }, - }) - - ctx := context.Background() - svc := newPermissionService() - result, err := svc.GetProjectPermission(ctx, &permission.GetProjectPermissionArgs{ - Actor: auth.ActorTypeIdentity, - ActorID: uuid.MustParse(identity.ID), - ProjectID: proj.ID, - ActorAuthMethod: "", - ActorOrgID: uuid.MustParse(nodejs.OrgID()), - ActionProjectType: permission.ActionProjectTypeSecretManager, - }) - require.NoError(t, err) - ability := result.Permission.Ability - - assert.False(t, gocasl.Can(ability, project.SecretActionDescribeAndReadValue, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.SecretActionCreate, project.SecretSubject{})) - assert.False(t, gocasl.Can(ability, project.ProjectActionDelete, project.ProjectSubject{})) - assert.False(t, gocasl.Can(ability, project.MemberActionRead, project.MemberSubject{})) -} diff --git a/backend-go/tests/platform/projects/projects_test.go b/backend-go/tests/platform/projects/projects_test.go deleted file mode 100644 index 4b1c1c92d8f..00000000000 --- a/backend-go/tests/platform/projects/projects_test.go +++ /dev/null @@ -1,84 +0,0 @@ -//go:build integration - -package projects_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/server/api/platform/projects" - "github.com/infisical/api/internal/services/permission" - "github.com/infisical/api/tests/infra" -) - -var stack *infra.Stack - -func TestMain(m *testing.M) { - stack = infra.New(). - WithPostgres(). - WithRedis(). - WithNodeJSApi(). - MustStart() - - code := m.Run() - stack.Stop() - os.Exit(code) -} - -// newProjectsRouter creates a projects router for HTTP testing. -func newProjectsRouter(t *testing.T) http.Handler { - t.Helper() - - ctx := t.Context() - permLib := permission.NewService(ctx, infra.NopLogger(), &permission.Deps{DB: stack.DB()}) - - handler := projects.NewHandler(&projects.Deps{ - Logger: infra.NopLogger(), - Permission: permLib, - }) - - return projects.NewRouter(handler) -} - -func TestGetHealth_ReturnsOK(t *testing.T) { - router := newProjectsRouter(t) - - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/health", http.NoBody) - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusOK, rec.Code) - - var resp projects.GetHealthResponse - err := json.NewDecoder(rec.Body).Decode(&resp) - require.NoError(t, err) - assert.Equal(t, "projects service is healthy", resp.Message) -} - -func TestCreateProject_Success(t *testing.T) { - router := newProjectsRouter(t) - - body := `{"name":"my-new-project","orgId":"` + stack.NodeJS().OrgID() + `"}` - req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusCreated, rec.Code) - - var resp projects.CreateProjectResponse - err := json.NewDecoder(rec.Body).Decode(&resp) - require.NoError(t, err) - assert.Equal(t, "my-new-project", resp.Name) - assert.Equal(t, stack.NodeJS().OrgID(), resp.OrgID) - assert.NotEmpty(t, resp.ID) -} diff --git a/backend-go/tests/platform/ratelimit/ratelimit_test.go b/backend-go/tests/platform/ratelimit/ratelimit_test.go deleted file mode 100644 index 77003c28890..00000000000 --- a/backend-go/tests/platform/ratelimit/ratelimit_test.go +++ /dev/null @@ -1,433 +0,0 @@ -//go:build integration - -package ratelimit_test - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/goleak" - - "github.com/infisical/api/internal/ee/services/license" - "github.com/infisical/api/internal/ee/services/ratelimit" - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/tests/infra" -) - -var stack *infra.Stack - -func TestMain(m *testing.M) { - stack = infra.New(). - WithPostgres(). - WithRedis(). - MustStart() - - code := m.Run() - - stack.Stop() - - if code == 0 { - if err := goleak.Find( - goleak.IgnoreTopFunction("github.com/redis/go-redis/v9/internal/pool.(*ConnPool).reaper"), - ); err != nil { - fmt.Fprintf(os.Stderr, "goleak: %v\n", err) - os.Exit(1) - } - } - - os.Exit(code) -} - -type mockLicenseService struct { - getPlanFn func(ctx context.Context, orgID string) (*license.FeatureSet, error) -} - -func (m *mockLicenseService) GetPlan(ctx context.Context, orgID string) (*license.FeatureSet, error) { - if m.getPlanFn != nil { - return m.getPlanFn(ctx, orgID) - } - return &license.FeatureSet{}, nil -} - -func newTestService(t *testing.T, isCloud bool) *ratelimit.Service { - t.Helper() - return ratelimit.NewService(context.Background(), infra.NopLogger(), &ratelimit.Deps{ - Redis: stack.Redis().Client(), - LicenseSvc: &mockLicenseService{}, - IsCloud: isCloud, - IsEnabled: true, - }) -} - -// ========================================================================== -// Counter Integration Tests -// ========================================================================== - -func TestRedisCounter_IncrementAndGet_BasicFlow(t *testing.T) { - client := stack.Redis().Client() - defer client.Close() - - counter := ratelimit.NewRedisCounter(&ratelimit.RedisCounterConfig{ - Client: client, - PrefixKey: "test-basic-" + t.Name(), - Logger: infra.NopLogger(), - }) - counter.Config(100, time.Minute) - - window := time.Now().UTC().Truncate(time.Minute) - prevWindow := window.Add(-time.Minute) - - err := counter.Increment("test-key", window) - require.NoError(t, err) - - err = counter.Increment("test-key", window) - require.NoError(t, err) - - curr, prev, err := counter.Get("test-key", window, prevWindow) - require.NoError(t, err) - - assert.Equal(t, 2, curr) - assert.Equal(t, 0, prev) -} - -func TestRedisCounter_Get_ReturnsZeroForNewKey(t *testing.T) { - client := stack.Redis().Client() - defer client.Close() - - counter := ratelimit.NewRedisCounter(&ratelimit.RedisCounterConfig{ - Client: client, - PrefixKey: "test-zero-" + t.Name(), - Logger: infra.NopLogger(), - }) - counter.Config(100, time.Minute) - - window := time.Now().UTC().Truncate(time.Minute) - prevWindow := window.Add(-time.Minute) - - curr, prev, err := counter.Get("nonexistent-key", window, prevWindow) - require.NoError(t, err) - - assert.Equal(t, 0, curr) - assert.Equal(t, 0, prev) -} - -func TestRedisCounter_SeparateWindows_HaveIndependentCounts(t *testing.T) { - client := stack.Redis().Client() - defer client.Close() - - counter := ratelimit.NewRedisCounter(&ratelimit.RedisCounterConfig{ - Client: client, - PrefixKey: "test-windows-" + t.Name(), - Logger: infra.NopLogger(), - }) - counter.Config(100, time.Minute) - - window1 := time.Now().UTC().Truncate(time.Minute) - window2 := window1.Add(-time.Minute) - - err := counter.Increment("key", window1) - require.NoError(t, err) - err = counter.Increment("key", window1) - require.NoError(t, err) - err = counter.Increment("key", window1) - require.NoError(t, err) - - err = counter.Increment("key", window2) - require.NoError(t, err) - - curr, prev, err := counter.Get("key", window1, window2) - require.NoError(t, err) - - assert.Equal(t, 3, curr) - assert.Equal(t, 1, prev) -} - -func TestRedisCounter_DifferentKeys_HaveIndependentCounts(t *testing.T) { - client := stack.Redis().Client() - defer client.Close() - - counter := ratelimit.NewRedisCounter(&ratelimit.RedisCounterConfig{ - Client: client, - PrefixKey: "test-keys-" + t.Name(), - Logger: infra.NopLogger(), - }) - counter.Config(100, time.Minute) - - window := time.Now().UTC().Truncate(time.Minute) - prevWindow := window.Add(-time.Minute) - - err := counter.Increment("ip-1", window) - require.NoError(t, err) - err = counter.Increment("ip-1", window) - require.NoError(t, err) - - err = counter.Increment("ip-2", window) - require.NoError(t, err) - - curr1, _, err := counter.Get("ip-1", window, prevWindow) - require.NoError(t, err) - assert.Equal(t, 2, curr1) - - curr2, _, err := counter.Get("ip-2", window, prevWindow) - require.NoError(t, err) - assert.Equal(t, 1, curr2) -} - -func TestRedisCounter_IncrementBy_IncrementsCorrectAmount(t *testing.T) { - client := stack.Redis().Client() - defer client.Close() - - counter := ratelimit.NewRedisCounter(&ratelimit.RedisCounterConfig{ - Client: client, - PrefixKey: "test-incrby-" + t.Name(), - Logger: infra.NopLogger(), - }) - counter.Config(100, time.Minute) - - window := time.Now().UTC().Truncate(time.Minute) - prevWindow := window.Add(-time.Minute) - - err := counter.IncrementBy("key", window, 5) - require.NoError(t, err) - - err = counter.IncrementBy("key", window, 3) - require.NoError(t, err) - - curr, _, err := counter.Get("key", window, prevWindow) - require.NoError(t, err) - assert.Equal(t, 8, curr) -} - -// ========================================================================== -// Middleware Integration Tests -// ========================================================================== - -func TestRateLimitMiddleware_AllowsRequestsUnderLimit(t *testing.T) { - svc := newTestService(t, true) - - handlerCallCount := 0 - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handlerCallCount++ - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.Middleware(ratelimit.PresetSecrets) - wrappedHandler := middleware(handler) - - for i := 0; i < 5; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.RemoteAddr = "192.168.1.100:12345" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code, "request %d should succeed", i) - } - - assert.Equal(t, 5, handlerCallCount) -} - -func TestRateLimitMiddleware_BlocksAfterLimitExceeded(t *testing.T) { - svc := newTestService(t, true) - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.Middleware(ratelimit.PresetMfa) - wrappedHandler := middleware(handler) - - ip := "10.0.0.1:8080" - successCount := 0 - blockedCount := 0 - - for i := 0; i < 25; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.RemoteAddr = ip - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - - switch rec.Code { - case http.StatusOK: - successCount++ - case http.StatusTooManyRequests: - blockedCount++ - } - } - - assert.Equal(t, ratelimit.DefaultLimits.MfaRateLimit, successCount) - assert.Equal(t, 25-ratelimit.DefaultLimits.MfaRateLimit, blockedCount) -} - -func TestRateLimitMiddleware_ReturnsRateLimitHeaders(t *testing.T) { - svc := newTestService(t, true) - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.Middleware(ratelimit.PresetRead) - wrappedHandler := middleware(handler) - - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.RemoteAddr = "172.16.0.1:9999" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.NotEmpty(t, rec.Header().Get("X-RateLimit-Limit")) - assert.NotEmpty(t, rec.Header().Get("X-RateLimit-Remaining")) - assert.NotEmpty(t, rec.Header().Get("X-RateLimit-Reset")) -} - -func TestRateLimitMiddleware_DifferentIPsHaveIndependentLimits(t *testing.T) { - svc := newTestService(t, true) - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.Middleware(ratelimit.PresetMfa) - wrappedHandler := middleware(handler) - - ip1SuccessCount := 0 - for i := 0; i < 25; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.RemoteAddr = "10.1.1.1:8080" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - ip1SuccessCount++ - } - } - - ip2SuccessCount := 0 - for i := 0; i < 25; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.RemoteAddr = "10.2.2.2:8080" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - ip2SuccessCount++ - } - } - - assert.Equal(t, ratelimit.DefaultLimits.MfaRateLimit, ip1SuccessCount) - assert.Equal(t, ratelimit.DefaultLimits.MfaRateLimit, ip2SuccessCount) -} - -func TestRateLimitMiddleware_UsesDynamicLimitFromPlan(t *testing.T) { - licenseSvc := &mockLicenseService{ - getPlanFn: func(ctx context.Context, orgID string) (*license.FeatureSet, error) { - return &license.FeatureSet{ - RateLimits: license.RateLimits{ - SecretsLimit: 5, - }, - }, nil - }, - } - - svc := ratelimit.NewService(context.Background(), infra.NopLogger(), &ratelimit.Deps{ - Redis: stack.Redis().Client(), - LicenseSvc: licenseSvc, - IsCloud: true, - IsEnabled: true, - }) - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.Middleware(ratelimit.PresetSecrets) - wrappedHandler := middleware(handler) - - identity := &auth.Identity{ - OrgID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, - } - - successCount := 0 - for i := 0; i < 10; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req = req.WithContext(auth.WithIdentity(req.Context(), identity)) - req.RemoteAddr = "10.99.99.99:8080" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - successCount++ - } - } - - assert.Equal(t, 5, successCount, "should allow exactly 5 requests based on plan limit") -} - -func TestRateLimitMiddleware_GlobalMiddleware_AppliesDefaultLimit(t *testing.T) { - svc := newTestService(t, true) - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.GlobalMiddleware() - wrappedHandler := middleware(handler) - - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.RemoteAddr = "10.50.50.50:8080" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "600", rec.Header().Get("X-RateLimit-Limit")) -} - -func TestMfaMiddleware_KeysByAuthToken(t *testing.T) { - svc := newTestService(t, true) - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - middleware := svc.MfaMiddleware() - wrappedHandler := middleware(handler) - - token1SuccessCount := 0 - for i := 0; i < 25; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.Header.Set("Authorization", "Bearer token-user-1-unique") - req.RemoteAddr = "10.0.0.1:8080" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - token1SuccessCount++ - } - } - - token2SuccessCount := 0 - for i := 0; i < 25; i++ { - req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) - req.Header.Set("Authorization", "Bearer token-user-2-unique") - req.RemoteAddr = "10.0.0.1:8080" - rec := httptest.NewRecorder() - - wrappedHandler.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - token2SuccessCount++ - } - } - - assert.Equal(t, ratelimit.DefaultLimits.MfaRateLimit, token1SuccessCount) - assert.Equal(t, ratelimit.DefaultLimits.MfaRateLimit, token2SuccessCount) -} diff --git a/backend-go/tests/secretmanager/secrets/get_secret_by_name_integration_test.go b/backend-go/tests/secretmanager/secrets/get_secret_by_name_integration_test.go deleted file mode 100644 index 658d3ab2f7d..00000000000 --- a/backend-go/tests/secretmanager/secrets/get_secret_by_name_integration_test.go +++ /dev/null @@ -1,483 +0,0 @@ -//go:build integration - -package secrets_test - -import ( - "encoding/json" - "fmt" - "net/http/httptest" - "net/url" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/server/api/secretmanager/secret" - "github.com/infisical/api/internal/server/api/shared" - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/tests/infra" -) - -// httpGetSecretV4 makes a direct HTTP GET request to /api/v4/secrets/{secretName} -func httpGetSecretV4(t *testing.T, srv *httptest.Server, secretName string, params *GetSecretByNameV4Params) (body []byte, statusCode int) { - t.Helper() - - urlParams := url.Values{} - urlParams.Set("projectId", params.ProjectID) - urlParams.Set("environment", params.Environment) - if params.SecretPath != nil { - urlParams.Set("secretPath", *params.SecretPath) - } - if params.Version != nil { - urlParams.Set("version", strconv.Itoa(*params.Version)) - } - if params.Type != nil { - urlParams.Set("type", *params.Type) - } - if params.ViewSecretValue != nil { - urlParams.Set("viewSecretValue", strconv.FormatBool(*params.ViewSecretValue)) - } - if params.ExpandSecretReferences != nil { - urlParams.Set("expandSecretReferences", strconv.FormatBool(*params.ExpandSecretReferences)) - } - if params.IncludeImports != nil { - urlParams.Set("includeImports", strconv.FormatBool(*params.IncludeImports)) - } - - path := fmt.Sprintf("/api/v4/secrets/%s?%s", url.PathEscape(secretName), urlParams.Encode()) - return doGet(t, srv, path) -} - -// ============================================================================= -// Basic GetSecretByName Tests -// ============================================================================= - -func TestGetSecretByName_ReturnsSecret(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-basic-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "PLAIN_SECRET", "plain-value", nil) - - identity := nodejs.CreateIdentity(t, "get-basic-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "PLAIN_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "PLAIN_SECRET", result.Secret.SecretKey) - assert.Equal(t, "plain-value", result.Secret.SecretValue) -} - -func TestGetSecretByName_WithExpansion(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-expansion-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "HOST", "myhost.com", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "PORT", "5432", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ENDPOINT", "${HOST}:${PORT}", nil) - - identity := nodejs.CreateIdentity(t, "get-expansion-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "ENDPOINT", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "ENDPOINT", result.Secret.SecretKey) - assert.Equal(t, "myhost.com:5432", result.Secret.SecretValue, "should expand references") -} - -func TestGetSecretByName_WithoutExpansion(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-no-expansion-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "HOST", "myhost.com", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ENDPOINT", "${HOST}:8080", nil) - - identity := nodejs.CreateIdentity(t, "get-no-expansion-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "ENDPOINT", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(false), - }) - - require.NoError(t, err) - assert.Equal(t, "ENDPOINT", result.Secret.SecretKey) - assert.Equal(t, "${HOST}:8080", result.Secret.SecretValue, "should not expand references") -} - -func TestGetSecretByName_NotFound(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-not-found-test") - - identity := nodejs.CreateIdentity(t, "get-not-found-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - _, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "NON_EXISTENT_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -func TestGetSecretByName_WithComment(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-comment-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "COMMENTED_SECRET", "commented-value", &infra.CreateSecretOpts{ - Comment: "This is a comment for the secret", - }) - - identity := nodejs.CreateIdentity(t, "get-comment-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "COMMENTED_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "COMMENTED_SECRET", result.Secret.SecretKey) - assert.Equal(t, "This is a comment for the secret", result.Secret.SecretComment) -} - -func TestGetSecretByName_WithMetadata(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-metadata-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "METADATA_SECRET", "metadata-value", &infra.CreateSecretOpts{ - Metadata: []infra.SecretMetadataEntry{ - {Key: "env", Value: "production"}, - {Key: "owner", Value: "platform-team"}, - }, - }) - - identity := nodejs.CreateIdentity(t, "get-metadata-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "METADATA_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "METADATA_SECRET", result.Secret.SecretKey) - require.NotEmpty(t, result.Secret.SecretMetadata) - - metadataMap := make(map[string]string) - for _, m := range result.Secret.SecretMetadata { - metadataMap[m.Key] = m.Value - } - assert.Equal(t, "production", metadataMap["env"]) - assert.Equal(t, "platform-team", metadataMap["owner"]) -} - -func TestGetSecretByName_WithReminder(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-reminder-test") - repeatDays := 30 - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "REMINDER_SECRET", "reminder-value", &infra.CreateSecretOpts{ - ReminderNote: "Remember to rotate this secret", - ReminderRepeatDays: &repeatDays, - }) - - identity := nodejs.CreateIdentity(t, "get-reminder-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "REMINDER_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "REMINDER_SECRET", result.Secret.SecretKey) - require.NotNil(t, result.Secret.SecretReminderNote, "secretReminderNote should be present") - assert.Equal(t, "Remember to rotate this secret", *result.Secret.SecretReminderNote) - require.NotNil(t, result.Secret.SecretReminderRepeatDays, "secretReminderRepeatDays should be present") - assert.Equal(t, 30, *result.Secret.SecretReminderRepeatDays) -} - -func TestGetSecretByName_WithTagColor(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-tag-color-test") - - tag := nodejs.CreateTag(t, proj.ID, "important", "Important", "#ff0000") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "TAGGED_SECRET", "tagged-value", &infra.CreateSecretOpts{ - TagIDs: []string{tag.ID}, - }) - - identity := nodejs.CreateIdentity(t, "get-tag-color-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "TAGGED_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "TAGGED_SECRET", result.Secret.SecretKey) - require.Len(t, result.Secret.Tags, 1, "should have one tag") - assert.Equal(t, "important", result.Secret.Tags[0].Slug) - require.NotNil(t, result.Secret.Tags[0].Color, "tag color should be present") - assert.Equal(t, "#ff0000", *result.Secret.Tags[0].Color) -} - -// ============================================================================= -// Expansion Without Imports Tests -// ============================================================================= - -func TestGetSecretByName_ExpandsSameFolderRefsWithoutImports(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "no-imports-expansion-test") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "BASE_URL", "https://api.example.com", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "ENDPOINT", "${BASE_URL}/v1/users", nil) - - identity := nodejs.CreateIdentity(t, "no-imports-expansion-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "ENDPOINT", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "ENDPOINT", result.Secret.SecretKey) - assert.Equal(t, "https://api.example.com/v1/users", result.Secret.SecretValue, - "should expand same-folder reference even without imports configured") -} - -func TestGetSecretByName_ExpandsNestedRefsWithoutImports(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "nested-no-imports-test") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "HOST", "db.example.com", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "PORT", "5432", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "CONNECTION", "${HOST}:${PORT}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "FULL_DSN", "postgres://user@${CONNECTION}/mydb", nil) - - identity := nodejs.CreateIdentity(t, "nested-no-imports-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "FULL_DSN", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "FULL_DSN", result.Secret.SecretKey) - assert.Equal(t, "postgres://user@db.example.com:5432/mydb", result.Secret.SecretValue, - "should expand nested references without imports") -} - -// ============================================================================= -// V3 Raw Endpoint Tests -// ============================================================================= - -func TestGetSecretByNameRawV3_WithWorkspaceSlug(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "v3-get-slug-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "V3_SECRET", "v3-value", nil) - - result, err := getSecretByNameRawV3AsUser(t, nodejs.UserID(), nodejs.OrgID(), "V3_SECRET", &GetSecretByNameV3Params{ - WorkspaceSlug: new(proj.Slug), - Environment: new(proj.EnvSlug), - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "V3_SECRET", result.Secret.SecretKey) - assert.Equal(t, "v3-value", result.Secret.SecretValue) -} - -func TestGetSecretByNameRawV3_WithWorkspaceId(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "v3-get-id-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "V3_ID_SECRET", "v3-id-value", nil) - - result, err := getSecretByNameRawV3AsUser(t, nodejs.UserID(), nodejs.OrgID(), "V3_ID_SECRET", &GetSecretByNameV3Params{ - WorkspaceID: new(proj.ID), - Environment: new(proj.EnvSlug), - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "V3_ID_SECRET", result.Secret.SecretKey) - assert.Equal(t, "v3-id-value", result.Secret.SecretValue) -} - -// ============================================================================= -// Import Tests -// ============================================================================= - -func TestGetSecretByName_FromImport(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-import-test") - - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_SECRET", "staging-value", nil) - nodejs.CreateSecretImport(t, proj.ID, "dev", "/", "staging", "/") - - identity := nodejs.CreateIdentity(t, "get-import-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "STAGING_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "STAGING_SECRET", result.Secret.SecretKey) - assert.Equal(t, "staging-value", result.Secret.SecretValue) - assert.Equal(t, "staging", result.Secret.Environment, "should return actual source environment") -} - -func TestGetSecretByName_ImportNotFoundWhenExcluded(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-import-excluded-test") - - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_ONLY", "staging-value", nil) - nodejs.CreateSecretImport(t, proj.ID, "dev", "/", "staging", "/") - - identity := nodejs.CreateIdentity(t, "get-import-excluded-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - _, err := getSecretByName(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), "STAGING_ONLY", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(false), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") -} - -// ============================================================================= -// HTTP Tests - Verify request parsing and response serialization -// ============================================================================= - -func TestGetSecretByNameV4_HTTP(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "http-get-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "HTTP_GET_SECRET", "http-get-value", nil) - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "nested") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/nested", "NESTED_GET_SECRET", "nested-get-value", nil) - - identity := nodejs.CreateIdentity(t, "http-get-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - handler := newSecretsHandler(t) - srv := newTestServer(t, handler, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID()) - defer srv.Close() - - t.Run("success with path param and query params", func(t *testing.T) { - body, status := httpGetSecretV4(t, srv, "HTTP_GET_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - }) - - assert.Equal(t, 200, status) - var resp secret.GetSecretByNameV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - assert.Equal(t, "HTTP_GET_SECRET", resp.Secret.SecretKey) - assert.Equal(t, "http-get-value", resp.Secret.SecretValue) - }) - - t.Run("secretPath query param filters correctly", func(t *testing.T) { - body, status := httpGetSecretV4(t, srv, "NESTED_GET_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/nested"), - }) - - assert.Equal(t, 200, status) - var resp secret.GetSecretByNameV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - assert.Equal(t, "NESTED_GET_SECRET", resp.Secret.SecretKey) - assert.Equal(t, "nested-get-value", resp.Secret.SecretValue) - }) - - t.Run("missing projectId returns 400", func(t *testing.T) { - body, status := httpGetSecretV4(t, srv, "HTTP_GET_SECRET", &GetSecretByNameV4Params{ - Environment: proj.EnvSlug, - }) - - assert.Equal(t, 400, status) - var resp shared.Error - require.NoError(t, json.Unmarshal(body, &resp)) - assert.NotEmpty(t, resp.Message) - }) - - t.Run("missing environment returns 400", func(t *testing.T) { - body, status := httpGetSecretV4(t, srv, "HTTP_GET_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - }) - - assert.Equal(t, 400, status) - var resp shared.Error - require.NoError(t, json.Unmarshal(body, &resp)) - assert.NotEmpty(t, resp.Message) - }) - - t.Run("secret not found returns 404", func(t *testing.T) { - body, status := httpGetSecretV4(t, srv, "NON_EXISTENT", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - }) - - assert.Equal(t, 404, status) - var resp shared.Error - require.NoError(t, json.Unmarshal(body, &resp)) - assert.Contains(t, resp.Message, "not found") - }) -} diff --git a/backend-go/tests/secretmanager/secrets/get_secret_by_name_permission_integration_test.go b/backend-go/tests/secretmanager/secrets/get_secret_by_name_permission_integration_test.go deleted file mode 100644 index 56de3fd0120..00000000000 --- a/backend-go/tests/secretmanager/secrets/get_secret_by_name_permission_integration_test.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build integration - -package secrets_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/tests/infra" -) - -// ============================================================================= -// GetSecretByName Import Permission Tests -// ============================================================================= - -func TestGetSecretByName_ImportPermissions(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "get-secret-import-perm-test") - - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_SECRET", "staging-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "ANOTHER_STAGING", "another-staging-value", nil) - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "DEV_DIRECT", "dev-direct-value", nil) - - nodejs.CreateSecretImport(t, proj.ID, "dev", "/", "staging", "/") - - devOnlyRole := nodejs.CreateCustomProjectRole(t, proj.ID, "dev-only-reader", "Dev Only", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read"}, - Conditions: map[string]any{ - "environment": "dev", - }, - }, - }) - devOnlyIdentity := nodejs.CreateIdentity(t, "dev-only-identity") - nodejs.AddIdentityToProject(t, proj.ID, devOnlyIdentity.ID, infra.Role(devOnlyRole.Slug)) - - adminIdentity := nodejs.CreateIdentity(t, "admin-identity") - nodejs.AddIdentityToProject(t, proj.ID, adminIdentity.ID, infra.Role("admin")) - - t.Run("direct secret allowed with env-scoped permission", func(t *testing.T) { - result, err := getSecretByName(t, auth.ActorTypeIdentity, devOnlyIdentity.ID, nodejs.OrgID(), "DEV_DIRECT", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(false), - }) - - require.NoError(t, err) - assert.Equal(t, "DEV_DIRECT", result.Secret.SecretKey) - assert.Equal(t, "dev-direct-value", result.Secret.SecretValue) - }) - - t.Run("imported secret denied without source env permission", func(t *testing.T) { - _, err := getSecretByName(t, auth.ActorTypeIdentity, devOnlyIdentity.ID, nodejs.OrgID(), "STAGING_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(true), - }) - - require.Error(t, err, "should deny access to imported secret when lacking source env permission") - assert.Contains(t, err.Error(), "Permission") - }) - - t.Run("imported secret allowed with admin permission", func(t *testing.T) { - result, err := getSecretByName(t, auth.ActorTypeIdentity, adminIdentity.ID, nodejs.OrgID(), "STAGING_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(true), - }) - - require.NoError(t, err) - assert.Equal(t, "STAGING_SECRET", result.Secret.SecretKey) - assert.Equal(t, "staging-value", result.Secret.SecretValue) - assert.Equal(t, "staging", result.Secret.Environment, "should return actual source environment") - }) - - t.Run("imported secret not found when includeImports is false", func(t *testing.T) { - _, err := getSecretByName(t, auth.ActorTypeIdentity, adminIdentity.ID, nodejs.OrgID(), "STAGING_SECRET", &GetSecretByNameV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(false), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) -} diff --git a/backend-go/tests/secretmanager/secrets/list_secrets_integration_test.go b/backend-go/tests/secretmanager/secrets/list_secrets_integration_test.go deleted file mode 100644 index 70a0e57bf78..00000000000 --- a/backend-go/tests/secretmanager/secrets/list_secrets_integration_test.go +++ /dev/null @@ -1,1008 +0,0 @@ -//go:build integration - -package secrets_test - -import ( - "context" - "encoding/json" - "fmt" - "net/http/httptest" - "net/url" - "strconv" - "testing" - "time" - - "github.com/jackc/pgx/v5" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/server/api/secretmanager/secret" - "github.com/infisical/api/internal/server/api/shared" - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/tests/infra" -) - -// httpListSecretsV4 makes a direct HTTP GET request to /api/v4/secrets -func httpListSecretsV4(t *testing.T, srv *httptest.Server, params *ListSecretsV4Params) (body []byte, statusCode int) { - t.Helper() - - urlParams := url.Values{} - urlParams.Set("projectId", params.ProjectID) - urlParams.Set("environment", params.Environment) - if params.SecretPath != nil { - urlParams.Set("secretPath", *params.SecretPath) - } - if params.ViewSecretValue != nil { - urlParams.Set("viewSecretValue", strconv.FormatBool(*params.ViewSecretValue)) - } - if params.ExpandSecretReferences != nil { - urlParams.Set("expandSecretReferences", strconv.FormatBool(*params.ExpandSecretReferences)) - } - if params.Recursive != nil { - urlParams.Set("recursive", strconv.FormatBool(*params.Recursive)) - } - if params.IncludePersonalOverrides != nil { - urlParams.Set("includePersonalOverrides", strconv.FormatBool(*params.IncludePersonalOverrides)) - } - if params.IncludeImports != nil { - urlParams.Set("includeImports", strconv.FormatBool(*params.IncludeImports)) - } - if params.TagSlugs != nil { - urlParams.Set("tagSlugs", *params.TagSlugs) - } - if params.MetadataFilter != nil { - urlParams.Set("metadataFilter", *params.MetadataFilter) - } - - path := fmt.Sprintf("/api/v4/secrets?%s", urlParams.Encode()) - return doGet(t, srv, path) -} - -func TestListSecrets_Basic(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "basic-test") - - tag1 := nodejs.CreateTag(t, proj.ID, "env-prod", "Production", "#FF0000") - tag2 := nodejs.CreateTag(t, proj.ID, "sensitive", "Sensitive", "#0000FF") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "PLAIN_SECRET", "plain-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ENCRYPTED_SECRET", "decrypted-correctly", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "TAGGED_SECRET", "tagged-value", &infra.CreateSecretOpts{TagIDs: []string{tag1.ID, tag2.ID}}) - - identity := nodejs.CreateIdentity(t, "basic-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - getSecretByKey := func(secrets []secret.SecretRaw, key string) *secret.SecretRaw { - for i := range secrets { - if secrets[i].SecretKey == key { - return &secrets[i] - } - } - return nil - } - - t.Run("returns correct structure", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "PLAIN_SECRET") - require.NotNil(t, secretItem) - - assert.NotEmpty(t, secretItem.ID) - assert.Equal(t, "PLAIN_SECRET", secretItem.SecretKey) - assert.Equal(t, "plain-value", secretItem.SecretValue) - assert.Equal(t, proj.EnvSlug, secretItem.Environment) - assert.NotEmpty(t, secretItem.Workspace) - assert.NotEmpty(t, secretItem.CreatedAt) - assert.NotEmpty(t, secretItem.UpdatedAt) - assert.Equal(t, 1, secretItem.Version) - assert.Equal(t, secret.Shared, secretItem.Type) - }) - - t.Run("decrypts values", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "ENCRYPTED_SECRET") - require.NotNil(t, secretItem) - assert.Equal(t, "decrypted-correctly", secretItem.SecretValue) - }) - - t.Run("includes tags", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "TAGGED_SECRET") - require.NotNil(t, secretItem) - require.Len(t, secretItem.Tags, 2) - - tagSlugs := make([]string, len(secretItem.Tags)) - for i, tag := range secretItem.Tags { - tagSlugs[i] = tag.Slug - } - assert.Contains(t, tagSlugs, "env-prod") - assert.Contains(t, tagSlugs, "sensitive") - }) - - t.Run("returns multiple secrets", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 3) - - keys := make([]string, len(result.Secrets)) - for i, s := range result.Secrets { - keys[i] = s.SecretKey - } - assert.Contains(t, keys, "PLAIN_SECRET") - assert.Contains(t, keys, "ENCRYPTED_SECRET") - assert.Contains(t, keys, "TAGGED_SECRET") - }) -} - -func TestListSecrets_Imports(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "imports-test") - - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_DB_URL", "staging-db-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_API_KEY", "staging-api-value", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "DEV_SECRET", "dev-value", nil) - nodejs.CreateSecretImport(t, proj.ID, "dev", "/", "staging", "/") - - identity := nodejs.CreateIdentity(t, "imports-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("include imports returns imported secrets", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "DEV_SECRET", result.Secrets[0].SecretKey) - - require.Len(t, result.Imports, 1) - assert.Equal(t, "staging", result.Imports[0].Environment) - assert.Equal(t, "/", result.Imports[0].SecretPath) - require.Len(t, result.Imports[0].Secrets, 2) - - importKeys := make([]string, len(result.Imports[0].Secrets)) - for i, s := range result.Imports[0].Secrets { - importKeys[i] = s.SecretKey - } - assert.Contains(t, importKeys, "STAGING_DB_URL") - assert.Contains(t, importKeys, "STAGING_API_KEY") - }) - - t.Run("exclude imports omits imported secrets", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(false), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "DEV_SECRET", result.Secrets[0].SecretKey) - assert.Nil(t, result.Imports, "imports should not be included when IncludeImports=false") - }) -} - -func TestListSecrets_ExpansionWithImports(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "expansion-imports-test") - - nodejs.CreateFolder(t, proj.ID, "prod", "/", "config") - nodejs.CreateFolder(t, proj.ID, "staging", "/", "services") - nodejs.CreateFolder(t, proj.ID, "dev", "/", "app") - - nodejs.CreateSecret(t, proj.ID, "prod", "/", "PROD_ROOT", "prod-root", nil) - nodejs.CreateSecret(t, proj.ID, "prod", "/config", "PROD_DB_HOST", "prod-db.example.com", nil) - nodejs.CreateSecret(t, proj.ID, "prod", "/config", "SHARED_KEY", "prod-shared-value", nil) - - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_API_URL", "https://staging-api.example.com", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "SHARED_KEY", "staging-shared-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "IMPORT_PRIORITY_KEY", "from-first-import", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/services", "SERVICE_URL", "https://staging-service.example.com", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/services", "IMPORT_PRIORITY_KEY", "from-second-import", nil) - nodejs.CreateSecretImport(t, proj.ID, "staging", "/", "prod", "/config") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "LOCAL_SECRET", "local-only", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "SHARED_KEY", "dev-shared-value", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/app", "APP_CONFIG", "app-config", nil) - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_LOCAL", "${LOCAL_SECRET}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_STAGING", "${STAGING_API_URL}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_SHARED", "${SHARED_KEY}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_SERVICE", "${SERVICE_URL}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_PROD_VIA_STAGING", "${PROD_DB_HOST}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_CHAIN", "host=${PROD_DB_HOST}&api=${STAGING_API_URL}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_MISSING", "${NOT_EXISTS}", nil) - nodejs.CreateSecret(t, proj.ID, "dev", "/", "REF_IMPORT_PRIORITY", "${IMPORT_PRIORITY_KEY}", nil) - - nodejs.CreateSecretImport(t, proj.ID, "dev", "/", "staging", "/") - nodejs.CreateSecretImport(t, proj.ID, "dev", "/", "staging", "/services") - nodejs.CreateSecretImport(t, proj.ID, "dev", "/app", "prod", "/config") - - nodejs.CreateSecret(t, proj.ID, "dev", "/app", "APP_DB_URL", "postgres://${PROD_DB_HOST}:5432/app", nil) - - identity := nodejs.CreateIdentity(t, "expansion-imports-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("root level expansion", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludeImports: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - - secretValues := make(map[string]string) - for _, s := range result.Secrets { - secretValues[s.SecretKey] = s.SecretValue - } - - assert.Equal(t, "local-only", secretValues["REF_LOCAL"], "should expand from local") - assert.Equal(t, "https://staging-api.example.com", secretValues["REF_STAGING"], "should expand from staging import") - assert.Equal(t, "dev-shared-value", secretValues["REF_SHARED"], "local should override imports") - assert.Equal(t, "from-second-import", secretValues["REF_IMPORT_PRIORITY"], "last import should win over first import") - assert.Equal(t, "https://staging-service.example.com", secretValues["REF_SERVICE"], "should expand from staging/services import") - assert.Equal(t, "prod-db.example.com", secretValues["REF_PROD_VIA_STAGING"], "should expand from prod via staging import") - assert.Equal(t, "host=prod-db.example.com&api=https://staging-api.example.com", secretValues["REF_CHAIN"], "multiple refs should expand") - assert.Empty(t, secretValues["REF_MISSING"], "missing ref should be empty") - assert.GreaterOrEqual(t, len(result.Imports), 2, "should have multiple imports") - }) - - t.Run("folder level expansion with folder import", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/app"), - ViewSecretValue: new(true), - IncludeImports: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - - secretValues := make(map[string]string) - for _, s := range result.Secrets { - secretValues[s.SecretKey] = s.SecretValue - } - - assert.Equal(t, "app-config", secretValues["APP_CONFIG"], "direct secret should be present") - assert.Equal(t, "postgres://prod-db.example.com:5432/app", secretValues["APP_DB_URL"], - "should expand using folder-level import from prod/config") - - require.Len(t, result.Imports, 1, "should have 1 folder import") - assert.Equal(t, "prod", result.Imports[0].Environment) - assert.Equal(t, "/config", result.Imports[0].SecretPath) - }) -} - -func TestListSecrets_Expansion(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "expansion-test") - nodejs.CreateEnvironment(t, proj.ID, "shared", "Shared") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "common") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "HOST", "myhost.com", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "PORT", "5432", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ENDPOINT", "${HOST}:${PORT}", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "FULL_URL", "https://${ENDPOINT}/api", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "BASE_VALUE", "base", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "REF_VALUE", "${BASE_VALUE}", nil) - - nodejs.CreateSecret(t, proj.ID, "shared", "/", "SHARED_API_KEY", "shared-api-key-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "CROSS_ENV_REF", "${shared.SHARED_API_KEY}", nil) - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/common", "COMMON_SECRET", "common-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "CROSS_PATH_REF", "${dev.common.COMMON_SECRET}", nil) - - identity := nodejs.CreateIdentity(t, "expansion-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - getSecretByKey := func(secrets []secret.SecretRaw, key string) *secret.SecretRaw { - for i := range secrets { - if secrets[i].SecretKey == key { - return &secrets[i] - } - } - return nil - } - - t.Run("nested expansion", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - - endpoint := getSecretByKey(result.Secrets, "ENDPOINT") - require.NotNil(t, endpoint) - assert.Equal(t, "myhost.com:5432", endpoint.SecretValue) - - fullURL := getSecretByKey(result.Secrets, "FULL_URL") - require.NotNil(t, fullURL) - assert.Equal(t, "https://myhost.com:5432/api", fullURL.SecretValue) - }) - - t.Run("cross environment expansion", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "CROSS_ENV_REF") - require.NotNil(t, secretItem) - assert.Equal(t, "shared-api-key-value", secretItem.SecretValue) - }) - - t.Run("cross path expansion", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "CROSS_PATH_REF") - require.NotNil(t, secretItem) - assert.Equal(t, "common-value", secretItem.SecretValue) - }) - - t.Run("no expansion preserves references", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - ExpandSecretReferences: new(false), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "REF_VALUE") - require.NotNil(t, secretItem) - assert.Equal(t, "${BASE_VALUE}", secretItem.SecretValue, "reference should NOT be expanded") - }) -} - -func TestListSecrets_PathAndRecursive(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "path-recursive-test") - - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "level1") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/level1", "level2") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "api") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "web") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ROOT_SECRET", "root-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/level1", "LEVEL1_SECRET", "level1-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/level1/level2", "LEVEL2_SECRET", "level2-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/api", "API_SECRET", "api-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/web", "WEB_SECRET", "web-value", nil) - - identity := nodejs.CreateIdentity(t, "path-recursive-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("recursive includes subfolders", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - Recursive: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 5, "recursive should return all secrets from all subfolders") - - keys := make([]string, len(result.Secrets)) - for i, s := range result.Secrets { - keys[i] = s.SecretKey - } - assert.Contains(t, keys, "ROOT_SECRET") - assert.Contains(t, keys, "LEVEL1_SECRET") - assert.Contains(t, keys, "LEVEL2_SECRET") - }) - - t.Run("non-recursive only current folder", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - Recursive: new(false), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1, "non-recursive should only return secrets from current folder") - assert.Equal(t, "ROOT_SECRET", result.Secrets[0].SecretKey) - }) - - t.Run("specific path", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/api"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "API_SECRET", result.Secrets[0].SecretKey) - }) -} - -func TestListSecrets_Errors(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "errors-test") - - identity := nodejs.CreateIdentity(t, "errors-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("environment not found", func(t *testing.T) { - _, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "nonexistent", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) - - t.Run("folder not found", func(t *testing.T) { - _, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/nonexistent/path"), - ViewSecretValue: new(true), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) -} - -func TestListSecrets_ReturnsComment(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "comment-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "SECRET_WITH_COMMENT", "secret-value", &infra.CreateSecretOpts{ - Comment: "This is a test comment for the secret", - }) - - identity := nodejs.CreateIdentity(t, "comment-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "SECRET_WITH_COMMENT", result.Secrets[0].SecretKey) - assert.Equal(t, "secret-value", result.Secrets[0].SecretValue) - assert.Equal(t, "This is a test comment for the secret", result.Secrets[0].SecretComment) -} - -func TestListSecrets_Metadata(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "metadata-test") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "SECRET_WITH_METADATA", "secret-value", &infra.CreateSecretOpts{ - Metadata: []infra.SecretMetadataEntry{ - {Key: "owner", Value: "platform-team"}, - {Key: "sensitivity", Value: "high"}, - }, - }) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "SECRET_WITH_COMMENT", "full-value", &infra.CreateSecretOpts{ - Comment: "A secret with both comment and metadata", - Metadata: []infra.SecretMetadataEntry{ - {Key: "env", Value: "production"}, - }, - }) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "SECRET_MIXED_ENCRYPTION", "encrypted-meta-value", &infra.CreateSecretOpts{ - Metadata: []infra.SecretMetadataEntry{ - {Key: "plaintext", Value: "plain-value", IsEncrypted: false}, - {Key: "sensitive", Value: "encrypted-value", IsEncrypted: true}, - }, - }) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "PROD_SECRET", "prod-value", &infra.CreateSecretOpts{ - Metadata: []infra.SecretMetadataEntry{{Key: "env", Value: "production"}}, - }) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "DEV_SECRET", "dev-value", &infra.CreateSecretOpts{ - Metadata: []infra.SecretMetadataEntry{{Key: "env", Value: "development"}}, - }) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "NO_METADATA", "no-meta-value", nil) - - identity := nodejs.CreateIdentity(t, "metadata-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - getSecretByKey := func(secrets []secret.SecretRaw, key string) *secret.SecretRaw { - for i := range secrets { - if secrets[i].SecretKey == key { - return &secrets[i] - } - } - return nil - } - - t.Run("returns metadata fields", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "SECRET_WITH_METADATA") - require.NotNil(t, secretItem) - - require.Len(t, secretItem.SecretMetadata, 2) - metadataMap := make(map[string]string) - for _, m := range secretItem.SecretMetadata { - metadataMap[m.Key] = m.Value - } - assert.Equal(t, "platform-team", metadataMap["owner"]) - assert.Equal(t, "high", metadataMap["sensitivity"]) - }) - - t.Run("comment and metadata together", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "SECRET_WITH_COMMENT") - require.NotNil(t, secretItem) - - assert.Equal(t, "full-value", secretItem.SecretValue) - assert.Equal(t, "A secret with both comment and metadata", secretItem.SecretComment) - require.Len(t, secretItem.SecretMetadata, 1) - assert.Equal(t, "env", secretItem.SecretMetadata[0].Key) - assert.Equal(t, "production", secretItem.SecretMetadata[0].Value) - }) - - t.Run("encrypted vs plaintext metadata", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - secretItem := getSecretByKey(result.Secrets, "SECRET_MIXED_ENCRYPTION") - require.NotNil(t, secretItem) - require.Len(t, secretItem.SecretMetadata, 2) - - metadataMap := make(map[string]*secret.ResourceMetadata) - for i := range secretItem.SecretMetadata { - m := &secretItem.SecretMetadata[i] - metadataMap[m.Key] = m - } - - plaintext := metadataMap["plaintext"] - require.NotNil(t, plaintext) - assert.Equal(t, "plain-value", plaintext.Value) - assert.False(t, plaintext.IsEncrypted) - - sensitive := metadataMap["sensitive"] - require.NotNil(t, sensitive) - assert.Equal(t, "encrypted-value", sensitive.Value) - assert.True(t, sensitive.IsEncrypted) - }) - - t.Run("filter by metadata", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - MetadataFilter: new("key=env,value=production"), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 2) - - keys := make([]string, len(result.Secrets)) - for i, s := range result.Secrets { - keys[i] = s.SecretKey - } - assert.Contains(t, keys, "PROD_SECRET") - assert.Contains(t, keys, "SECRET_WITH_COMMENT") - }) -} - -func TestListSecrets_Reminder(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "reminder-list-test") - - repeatDays := 7 - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "SECRET_WITH_REMINDER", "reminder-value", &infra.CreateSecretOpts{ - ReminderNote: "Rotate weekly", - ReminderRepeatDays: &repeatDays, - }) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "SECRET_WITHOUT_REMINDER", "no-reminder-value", nil) - - identity := nodejs.CreateIdentity(t, "reminder-list-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 2) - - var secretWithReminder, secretWithoutReminder *secret.SecretRaw - for i := range result.Secrets { - switch result.Secrets[i].SecretKey { - case "SECRET_WITH_REMINDER": - secretWithReminder = &result.Secrets[i] - case "SECRET_WITHOUT_REMINDER": - secretWithoutReminder = &result.Secrets[i] - } - } - - require.NotNil(t, secretWithReminder) - require.NotNil(t, secretWithReminder.SecretReminderNote, "secretReminderNote should be present") - assert.Equal(t, "Rotate weekly", *secretWithReminder.SecretReminderNote) - require.NotNil(t, secretWithReminder.SecretReminderRepeatDays, "secretReminderRepeatDays should be present") - assert.Equal(t, 7, *secretWithReminder.SecretReminderRepeatDays) - - require.NotNil(t, secretWithoutReminder) - assert.Nil(t, secretWithoutReminder.SecretReminderNote, "secretReminderNote should be nil for secret without reminder") - assert.Nil(t, secretWithoutReminder.SecretReminderRepeatDays, "secretReminderRepeatDays should be nil for secret without reminder") -} - -func TestListSecrets_PersonalOverrides(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "personal-test") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "MY_SECRET", "shared-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "MY_SECRET", "personal-value", &infra.CreateSecretOpts{ - Type: "personal", - }) - - identity := nodejs.CreateIdentity(t, "personal-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("never include returns shared only", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeUser, nodejs.UserID(), nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludePersonalOverrides: new(false), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "MY_SECRET", result.Secrets[0].SecretKey) - assert.Equal(t, "shared-value", result.Secrets[0].SecretValue) - }) - - t.Run("priority returns personal override", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeUser, nodejs.UserID(), nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludePersonalOverrides: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "MY_SECRET", result.Secrets[0].SecretKey) - assert.Equal(t, "personal-value", result.Secrets[0].SecretValue) - }) - - t.Run("identity sees shared value", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - IncludePersonalOverrides: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "MY_SECRET", result.Secrets[0].SecretKey) - assert.Equal(t, "shared-value", result.Secrets[0].SecretValue) - }) -} - -func TestListSecrets_TagFiltering(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "tag-filter-test") - tag1 := nodejs.CreateTag(t, proj.ID, "api", "API", "#FF0000") - tag2 := nodejs.CreateTag(t, proj.ID, "database", "Database", "#00FF00") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "API_KEY", "api-key-value", &infra.CreateSecretOpts{TagIDs: []string{tag1.ID}}) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "DB_PASSWORD", "db-password", &infra.CreateSecretOpts{TagIDs: []string{tag2.ID}}) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "UNTAGGED_SECRET", "untagged-value", nil) - - identity := nodejs.CreateIdentity(t, "tag-filter-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("filter by single tag", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - TagSlugs: new("api"), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "API_KEY", result.Secrets[0].SecretKey) - }) - - t.Run("filter by multiple tags", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - TagSlugs: new("api,database"), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 2) - - keys := make([]string, len(result.Secrets)) - for i, s := range result.Secrets { - keys[i] = s.SecretKey - } - assert.Contains(t, keys, "API_KEY") - assert.Contains(t, keys, "DB_PASSWORD") - }) -} - -func TestListSecretsRawV3(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "v3-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "V3_SECRET", "v3-value", nil) - - t.Run("with workspace slug", func(t *testing.T) { - result, err := listSecretsRawV3AsUser(t, nodejs.UserID(), nodejs.OrgID(), &ListSecretsV3Params{ - WorkspaceSlug: new(proj.Slug), - Environment: new(proj.EnvSlug), - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "V3_SECRET", result.Secrets[0].SecretKey) - }) - - t.Run("requires workspace id or slug", func(t *testing.T) { - _, err := listSecretsRawV3AsUser(t, nodejs.UserID(), nodejs.OrgID(), &ListSecretsV3Params{ - Environment: new("dev"), - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "workspaceId or workspaceSlug") - }) -} - -// ============================================================================= -// HTTP Tests - Verify request parsing and response serialization -// ============================================================================= - -func TestListSecretsV4_HTTP(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "http-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "HTTP_SECRET", "http-value", nil) - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "nested") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/nested", "path") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/nested/path", "NESTED_SECRET", "nested-value", nil) - - identity := nodejs.CreateIdentity(t, "http-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - handler := newSecretsHandler(t) - srv := newTestServer(t, handler, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID()) - defer srv.Close() - - t.Run("success with required params", func(t *testing.T) { - body, status := httpListSecretsV4(t, srv, &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - }) - - assert.Equal(t, 200, status) - var resp secret.ListSecretsV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - require.Len(t, resp.Secrets, 1) - assert.Equal(t, "HTTP_SECRET", resp.Secrets[0].SecretKey) - }) - - t.Run("secretPath filters correctly", func(t *testing.T) { - body, status := httpListSecretsV4(t, srv, &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/nested/path"), - }) - - assert.Equal(t, 200, status) - var resp secret.ListSecretsV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - require.Len(t, resp.Secrets, 1) - assert.Equal(t, "NESTED_SECRET", resp.Secrets[0].SecretKey) - }) - - t.Run("recursive returns all secrets", func(t *testing.T) { - body, status := httpListSecretsV4(t, srv, &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - Recursive: new(true), - }) - - assert.Equal(t, 200, status) - var resp secret.ListSecretsV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - require.Len(t, resp.Secrets, 2) - - secretKeys := []string{resp.Secrets[0].SecretKey, resp.Secrets[1].SecretKey} - assert.Contains(t, secretKeys, "HTTP_SECRET") - assert.Contains(t, secretKeys, "NESTED_SECRET") - }) - - t.Run("missing projectId returns 400", func(t *testing.T) { - body, status := httpListSecretsV4(t, srv, &ListSecretsV4Params{ - Environment: proj.EnvSlug, - }) - - assert.Equal(t, 400, status) - var resp shared.Error - require.NoError(t, json.Unmarshal(body, &resp)) - assert.NotEmpty(t, resp.Message) - }) - - t.Run("missing environment returns 400", func(t *testing.T) { - body, status := httpListSecretsV4(t, srv, &ListSecretsV4Params{ - ProjectID: proj.ID, - }) - - assert.Equal(t, 400, status) - var resp shared.Error - require.NoError(t, json.Unmarshal(body, &resp)) - assert.NotEmpty(t, resp.Message) - }) -} - -func TestListSecrets_SoftDeletedEnvironment(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "soft-delete-env-test") - - customEnv := nodejs.CreateEnvironment(t, proj.ID, "custom-env", "Custom Environment") - - nodejs.CreateSecret(t, proj.ID, "custom-env", "/", "CUSTOM_SECRET", "custom-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "DEV_SECRET", "dev-value", nil) - - identity := nodejs.CreateIdentity(t, "soft-delete-env-test-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - t.Run("secrets accessible before soft delete", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "custom-env", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "CUSTOM_SECRET", result.Secrets[0].SecretKey) - }) - - nodejs.SoftDeleteEnvironment(t, proj.ID, customEnv.ID) - - t.Run("environment row still exists in DB with softDeletedAt set", func(t *testing.T) { - var softDeletedAt *time.Time - err := stack.DB().Replica().QueryRow(context.Background(), ` - SELECT "softDeletedAt" FROM project_environments WHERE id = @envID - `, pgx.NamedArgs{"envID": customEnv.ID}).Scan(&softDeletedAt) - - require.NoError(t, err, "environment row should still exist in database") - require.NotNil(t, softDeletedAt, "softDeletedAt should be set (not NULL)") - }) - - t.Run("soft deleted environment returns not found", func(t *testing.T) { - _, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "custom-env", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) - - t.Run("other environments still work after soft delete", func(t *testing.T) { - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "DEV_SECRET", result.Secrets[0].SecretKey) - }) -} diff --git a/backend-go/tests/secretmanager/secrets/list_secrets_permission_integration_test.go b/backend-go/tests/secretmanager/secrets/list_secrets_permission_integration_test.go deleted file mode 100644 index eb9d4def696..00000000000 --- a/backend-go/tests/secretmanager/secrets/list_secrets_permission_integration_test.go +++ /dev/null @@ -1,671 +0,0 @@ -//go:build integration - -package secrets_test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/tests/infra" -) - -// ============================================================================= -// Identity Role-Based Access Tests -// ============================================================================= - -func TestIdentityAdmin_CanReadAllSecrets(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "admin-read-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ADMIN_SECRET_1", "value1", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "ADMIN_SECRET_2", "value2", nil) - - identity := nodejs.CreateIdentity(t, "admin-secrets-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 2) - - for _, secret := range result.Secrets { - assert.False(t, secret.SecretValueHidden, "admin should see secret values") - assert.NotEmpty(t, secret.SecretValue, "admin should see secret values") - } -} - -func TestIdentityMember_CanReadAllSecrets(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "member-read-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "MEMBER_SECRET", "member-value", nil) - - identity := nodejs.CreateIdentity(t, "member-secrets-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("member")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - - assert.False(t, result.Secrets[0].SecretValueHidden, "member should see secret values") - assert.Equal(t, "member-value", result.Secrets[0].SecretValue) -} - -func TestIdentityViewer_CanReadSecrets(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "viewer-read-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "VIEWER_SECRET", "viewer-visible-value", nil) - - identity := nodejs.CreateIdentity(t, "viewer-secrets-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("viewer")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - - assert.False(t, result.Secrets[0].SecretValueHidden, "viewer should see secret values") - assert.Equal(t, "viewer-visible-value", result.Secrets[0].SecretValue) -} - -func TestIdentityNoAccess_EmptyResult(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "noaccess-read-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "NOACCESS_SECRET", "secret-value", nil) - - identity := nodejs.CreateIdentity(t, "noaccess-secrets-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("no-access")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Empty(t, result.Secrets, "no-access role should see no secrets") -} - -func TestIdentityNotMember_Forbidden(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "notmember-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "FORBIDDEN_SECRET", "secret-value", nil) - - identity := nodejs.CreateIdentity(t, "outsider-secrets-identity") - - _, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "not a member") -} - -// ============================================================================= -// User Role-Based Access Tests -// ============================================================================= - -func TestUserAdmin_CanReadSecrets(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "user-admin-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "USER_ADMIN_SECRET", "admin-value", nil) - - user := nodejs.InviteAndCreateUser(t, "user-admin-secrets@test.local") - nodejs.AddUserToProject(t, proj.ID, user.Email, []string{"admin"}) - - result, err := listSecrets(t, auth.ActorTypeUser, user.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "admin-value", result.Secrets[0].SecretValue) -} - -func TestUserViewer_CanReadSecrets(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "user-viewer-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "USER_VIEWER_SECRET", "user-viewer-value", nil) - - user := nodejs.InviteAndCreateUser(t, "user-viewer-secrets@test.local") - nodejs.AddUserToProject(t, proj.ID, user.Email, []string{"viewer"}) - - result, err := listSecrets(t, auth.ActorTypeUser, user.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.False(t, result.Secrets[0].SecretValueHidden, "viewer should see secret values") - assert.Equal(t, "user-viewer-value", result.Secrets[0].SecretValue) -} - -// ============================================================================= -// Custom Role Tests -// ============================================================================= - -func TestIdentityCustomRole_EnvironmentScoped(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "env-scoped-test") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "DEV_SECRET", "dev-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_SECRET", "staging-value", nil) - - customRole := nodejs.CreateCustomProjectRole(t, proj.ID, "dev-only-reader", "Dev Only Reader", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read"}, - Conditions: map[string]any{ - "environment": "dev", - }, - }, - }) - - identity := nodejs.CreateIdentity(t, "env-scoped-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role(customRole.Slug)) - - devResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, devResult.Secrets, 1) - assert.Equal(t, "DEV_SECRET", devResult.Secrets[0].SecretKey) - - stagingResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "staging", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - assert.Empty(t, stagingResult.Secrets, "should not see staging secrets with dev-only role") -} - -func TestIdentityCustomRole_PathScoped(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "path-scoped-test") - - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "app") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/app", "config") - nodejs.CreateFolder(t, proj.ID, proj.EnvSlug, "/", "other") - - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/app", "APP_SECRET", "app-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/app/config", "CONFIG_SECRET", "config-value", nil) - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/other", "OTHER_SECRET", "other-value", nil) - - customRole := nodejs.CreateCustomProjectRole(t, proj.ID, "app-reader", "App Path Reader", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read"}, - Conditions: map[string]any{ - "secretPath": map[string]any{ - "$glob": "/app/**", - }, - }, - }, - }) - - identity := nodejs.CreateIdentity(t, "path-scoped-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role(customRole.Slug)) - - appResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/app"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, appResult.Secrets, 1) - assert.Equal(t, "APP_SECRET", appResult.Secrets[0].SecretKey) - - configResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/app/config"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, configResult.Secrets, 1) - assert.Equal(t, "CONFIG_SECRET", configResult.Secrets[0].SecretKey) - - otherResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/other"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - assert.Empty(t, otherResult.Secrets, "should not see /other secrets with /app/** role") -} - -// ============================================================================= -// Group Membership Tests -// ============================================================================= - -func TestGroupAdmin_UserInheritsAccess(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "group-admin-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "GROUP_ADMIN_SECRET", "group-value", nil) - - group := nodejs.CreateGroup(t, "secrets-admin-group") - user := nodejs.InviteAndCreateUser(t, "group-admin@test.local") - nodejs.AddUserToGroup(t, group.ID, user.Email) - nodejs.AddGroupToProject(t, proj.ID, group.ID, "admin") - - result, err := listSecrets(t, auth.ActorTypeUser, user.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "group-value", result.Secrets[0].SecretValue) - assert.False(t, result.Secrets[0].SecretValueHidden) -} - -func TestGroupViewer_UserInheritsReadAccess(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "group-viewer-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "GROUP_VIEWER_SECRET", "group-viewer-value", nil) - - group := nodejs.CreateGroup(t, "secrets-viewer-group") - user := nodejs.InviteAndCreateUser(t, "group-viewer@test.local") - nodejs.AddUserToGroup(t, group.ID, user.Email) - nodejs.AddGroupToProject(t, proj.ID, group.ID, "viewer") - - result, err := listSecrets(t, auth.ActorTypeUser, user.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.False(t, result.Secrets[0].SecretValueHidden, "group viewer should see secret values") - assert.Equal(t, "group-viewer-value", result.Secrets[0].SecretValue) -} - -func TestGroupCustomRole_UserInheritsCustomPermissions(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "group-custom-test") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "DEV_SECRET", "dev-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_SECRET", "staging-value", nil) - - customRole := nodejs.CreateCustomProjectRole(t, proj.ID, "group-dev-reader", "Group Dev Reader", []infra.Permission{ - { - Subject: "secrets", - Action: []string{"read"}, - Conditions: map[string]any{ - "environment": "dev", - }, - }, - }) - - group := nodejs.CreateGroup(t, "custom-role-group") - user := nodejs.InviteAndCreateUser(t, "group-custom@test.local") - nodejs.AddUserToGroup(t, group.ID, user.Email) - nodejs.AddGroupToProject(t, proj.ID, group.ID, customRole.Slug) - - devResult, err := listSecrets(t, auth.ActorTypeUser, user.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, devResult.Secrets, 1) - assert.Equal(t, "DEV_SECRET", devResult.Secrets[0].SecretKey) - - stagingResult, err := listSecrets(t, auth.ActorTypeUser, user.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "staging", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - assert.Empty(t, stagingResult.Secrets) -} - -// ============================================================================= -// Additional Privilege Tests -// ============================================================================= - -func TestIdentityAdditionalPrivilege_ExtendsRole(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "addl-priv-test") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "DEV_SECRET", "dev-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_SECRET", "staging-value", nil) - - identity := nodejs.CreateIdentity(t, "addl-priv-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("no-access")) - nodejs.CreateIdentityAdditionalPrivilege(t, identity.ID, proj.ID, []infra.Permission{ - { - Subject: "secrets", - Action: "read", - Conditions: map[string]any{ - "environment": "dev", - }, - }, - }, nil) - - devResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, devResult.Secrets, 1) - assert.Equal(t, "DEV_SECRET", devResult.Secrets[0].SecretKey) - - stagingResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "staging", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - assert.Empty(t, stagingResult.Secrets) -} - -func TestIdentityMultipleAdditionalPrivileges_Merge(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "multi-addl-priv-test") - - nodejs.CreateSecret(t, proj.ID, "dev", "/", "DEV_SECRET", "dev-value", nil) - nodejs.CreateSecret(t, proj.ID, "staging", "/", "STAGING_SECRET", "staging-value", nil) - - identity := nodejs.CreateIdentity(t, "multi-addl-priv-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("no-access")) - - nodejs.CreateIdentityAdditionalPrivilege(t, identity.ID, proj.ID, []infra.Permission{ - { - Subject: "secrets", - Action: "read", - Conditions: map[string]any{ - "environment": "dev", - }, - }, - }, nil) - nodejs.CreateIdentityAdditionalPrivilege(t, identity.ID, proj.ID, []infra.Permission{ - { - Subject: "secrets", - Action: "read", - Conditions: map[string]any{ - "environment": "staging", - }, - }, - }, nil) - - devResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "dev", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, devResult.Secrets, 1) - - stagingResult, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: "staging", - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - require.NoError(t, err) - require.Len(t, stagingResult.Secrets, 1) -} - -// ============================================================================= -// Temporary Access Tests -// ============================================================================= - -func TestIdentityTemporaryRole_ActiveGrantsAccess(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "temp-active-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "TEMP_SECRET", "temp-value", nil) - - identity := nodejs.CreateIdentity(t, "temp-active-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, []infra.RoleAssignment{ - { - Role: "no-access", - IsTemporary: false, - }, - { - Role: "admin", - IsTemporary: true, - TemporaryMode: "relative", - TemporaryRange: "1h", - TemporaryAccessStartTime: time.Now().UTC().Format(time.RFC3339), - }, - }) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "temp-value", result.Secrets[0].SecretValue) -} - -func TestIdentityTemporaryRole_ExpiredDeniesAccess(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "temp-expired-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "EXPIRED_SECRET", "expired-value", nil) - - identity := nodejs.CreateIdentity(t, "temp-expired-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, []infra.RoleAssignment{ - { - Role: "no-access", - IsTemporary: false, - }, - { - Role: "admin", - IsTemporary: true, - TemporaryMode: "relative", - TemporaryRange: "1h", - TemporaryAccessStartTime: time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339), - }, - }) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Empty(t, result.Secrets, "expired temporary role should not grant access") -} - -func TestIdentityTemporaryRole_MixedWithPermanent(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "temp-mixed-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "MIXED_SECRET", "mixed-value", nil) - - identity := nodejs.CreateIdentity(t, "temp-mixed-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, []infra.RoleAssignment{ - { - Role: "viewer", - IsTemporary: false, - }, - { - Role: "admin", - IsTemporary: true, - TemporaryMode: "relative", - TemporaryRange: "1h", - TemporaryAccessStartTime: time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339), - }, - }) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.False(t, result.Secrets[0].SecretValueHidden, "viewer role should allow reading values") - assert.Equal(t, "mixed-value", result.Secrets[0].SecretValue) -} - -func TestIdentityTemporaryAdditionalPrivilege_Active(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "temp-addl-active-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "TEMP_ADDL_SECRET", "temp-addl-value", nil) - - identity := nodejs.CreateIdentity(t, "temp-addl-active-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("no-access")) - nodejs.CreateIdentityAdditionalPrivilege(t, identity.ID, proj.ID, []infra.Permission{ - { - Subject: "secrets", - Action: "read", - }, - }, &infra.IdentityPrivilegeOpts{TemporaryRange: "1h", TemporaryAccessStartTime: time.Now().UTC().Format(time.RFC3339)}) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.Equal(t, "temp-addl-value", result.Secrets[0].SecretValue) -} - -func TestIdentityTemporaryAdditionalPrivilege_Expired(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "temp-addl-expired-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "EXPIRED_ADDL_SECRET", "expired-value", nil) - - identity := nodejs.CreateIdentity(t, "temp-addl-expired-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("no-access")) - nodejs.CreateIdentityAdditionalPrivilege(t, identity.ID, proj.ID, []infra.Permission{ - { - Subject: "secrets", - Action: "read", - }, - }, &infra.IdentityPrivilegeOpts{TemporaryRange: "1h", TemporaryAccessStartTime: time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339)}) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - assert.Empty(t, result.Secrets, "expired temporary additional privilege should not grant access") -} - -// ============================================================================= -// ViewSecretValue Permission Tests -// ============================================================================= - -func TestViewSecretValue_False_HidesValues(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "view-value-false-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "HIDDEN_VALUE_SECRET", "should-be-hidden", nil) - - identity := nodejs.CreateIdentity(t, "view-value-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(false), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.True(t, result.Secrets[0].SecretValueHidden, "value should be hidden when viewSecretValue=false") - assert.Equal(t, "", result.Secrets[0].SecretValue, "value should be masked when viewSecretValue=false") -} - -func TestViewSecretValue_True_ShowsValues(t *testing.T) { - nodejs := stack.NodeJS() - - proj := nodejs.CreateProject(t, "view-value-true-test") - nodejs.CreateSecret(t, proj.ID, proj.EnvSlug, "/", "VISIBLE_VALUE_SECRET", "should-be-visible", nil) - - identity := nodejs.CreateIdentity(t, "view-value-true-identity") - nodejs.AddIdentityToProject(t, proj.ID, identity.ID, infra.Role("admin")) - - result, err := listSecrets(t, auth.ActorTypeIdentity, identity.ID, nodejs.OrgID(), &ListSecretsV4Params{ - ProjectID: proj.ID, - Environment: proj.EnvSlug, - SecretPath: new("/"), - ViewSecretValue: new(true), - }) - - require.NoError(t, err) - require.Len(t, result.Secrets, 1) - assert.False(t, result.Secrets[0].SecretValueHidden, "value should not be hidden when viewSecretValue=true") - assert.Equal(t, "should-be-visible", result.Secrets[0].SecretValue) -} diff --git a/backend-go/tests/secretmanager/secrets/main_test.go b/backend-go/tests/secretmanager/secrets/main_test.go deleted file mode 100644 index 9a2492a9ba6..00000000000 --- a/backend-go/tests/secretmanager/secrets/main_test.go +++ /dev/null @@ -1,400 +0,0 @@ -//go:build integration - -package secrets_test - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "strconv" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/infisical/api/internal/queue" - "github.com/infisical/api/internal/server/api/secretmanager/secret" - "github.com/infisical/api/internal/server/api/shared" - "github.com/infisical/api/internal/services/auditlog" - "github.com/infisical/api/internal/services/auth" - "github.com/infisical/api/internal/services/kms" - "github.com/infisical/api/internal/services/permission" - "github.com/infisical/api/internal/services/project" - secretSvc "github.com/infisical/api/internal/services/secretmanager/secret" - "github.com/infisical/api/internal/services/secretmanager/secretfolder" - "github.com/infisical/api/internal/services/secretmanager/secretimport" - "github.com/infisical/api/tests/infra" -) - -var ( - stack *infra.Stack - testProject *infra.ProjectSeed -) - -func TestMain(m *testing.M) { - stack = infra.New(). - WithPostgres(). - WithRedis(). - WithNodeJSApi(). - WithEEFeatures("rbac", "groups"). - MustStart() - - testProject = stack.NodeJS().MustCreateProject("secrets-test") - code := m.Run() - stack.Stop() - os.Exit(code) -} - -// newSecretsHandler creates a secrets handler for direct testing. -func newSecretsHandler(t *testing.T) *secret.Handler { - t.Helper() - - ctx := t.Context() - - permSvc := permission.NewService(ctx, infra.NopLogger(), &permission.Deps{DB: stack.DB()}) - - redisClient := stack.Redis().Client() - t.Cleanup(func() { redisClient.Close() }) - - kmsSvc, err := kms.NewService(ctx, infra.NopLogger(), &kms.Deps{ - DB: stack.DB(), - HSM: nil, - ExternalKms: nil, - Config: stack.Config(), - }) - require.NoError(t, err) - - err = kmsSvc.Start(ctx, false) - require.NoError(t, err) - - projectSvc := project.NewService(ctx, infra.NopLogger(), &project.Deps{DB: stack.DB()}) - - queueSvc := queue.NewService(ctx, infra.NopLogger(), redisClient) - - auditLogSvc := auditlog.NewService(ctx, infra.NopLogger(), &auditlog.Deps{Queue: queueSvc, Config: stack.Config()}) - - secretFolderSvc := secretfolder.NewService(ctx, infra.NopLogger(), &secretfolder.Deps{DB: stack.DB()}) - secretImportSvc := secretimport.NewService(ctx, infra.NopLogger(), &secretimport.Deps{DB: stack.DB()}) - - secretsSvc := secretSvc.NewService(ctx, infra.NopLogger(), &secretSvc.Deps{ - DB: stack.DB(), - SecretFolderService: secretFolderSvc, - SecretImportService: secretImportSvc, - KMSService: kmsSvc, - }) - - return secret.NewHandler(&secret.Deps{ - Logger: infra.NopLogger(), - Permission: permSvc, - Project: projectSvc, - AuditLog: auditLogSvc, - Secrets: secretsSvc, - }) -} - -// doGet makes a GET request and returns the response body and status code. -func doGet(t *testing.T, srv *httptest.Server, path string) (body []byte, statusCode int) { - t.Helper() - - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+path, http.NoBody) - require.NoError(t, err) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - - body, err = io.ReadAll(resp.Body) - require.NoError(t, err) - - return body, resp.StatusCode -} - -// ListSecretsV3Params holds parameters for V3 list secrets request. -type ListSecretsV3Params struct { - WorkspaceID *string - WorkspaceSlug *string - Environment *string - SecretPath *string - ViewSecretValue *bool - ExpandSecretReferences *bool - Recursive *bool - IncludeImports *bool - TagSlugs *string - MetadataFilter *string -} - -// getSecretByNameRawV3AsUser is a helper that gets a secret by name using V3 API as a user via HTTP. -func getSecretByNameRawV3AsUser(t *testing.T, userID, orgID, secretName string, params *GetSecretByNameV3Params) (secret.GetSecretByNameV4Response, error) { - t.Helper() - - handler := newSecretsHandler(t) - srv := newTestServer(t, handler, auth.ActorTypeUser, userID, orgID) - defer srv.Close() - - // Build URL - urlParams := url.Values{} - if params.WorkspaceID != nil { - urlParams.Set("workspaceId", *params.WorkspaceID) - } - if params.WorkspaceSlug != nil { - urlParams.Set("workspaceSlug", *params.WorkspaceSlug) - } - if params.Environment != nil { - urlParams.Set("environment", *params.Environment) - } - if params.SecretPath != nil { - urlParams.Set("secretPath", *params.SecretPath) - } - if params.Version != nil { - urlParams.Set("version", strconv.Itoa(*params.Version)) - } - if params.Type != nil { - urlParams.Set("type", *params.Type) - } - if params.ViewSecretValue != nil { - urlParams.Set("viewSecretValue", strconv.FormatBool(*params.ViewSecretValue)) - } - if params.ExpandSecretReferences != nil { - urlParams.Set("expandSecretReferences", strconv.FormatBool(*params.ExpandSecretReferences)) - } - if params.IncludeImports != nil { - urlParams.Set("include_imports", strconv.FormatBool(*params.IncludeImports)) - } - - path := fmt.Sprintf("/api/v3/secrets/raw/%s?%s", url.PathEscape(secretName), urlParams.Encode()) - body, status := doGet(t, srv, path) - - if status >= 400 { - var errResp shared.Error - _ = json.Unmarshal(body, &errResp) - return secret.GetSecretByNameV4Response{}, errors.New(errResp.Message) - } - - var resp secret.GetSecretByNameV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - return resp, nil -} - -// GetSecretByNameV3Params holds parameters for V3 get secret request. -type GetSecretByNameV3Params struct { - WorkspaceID *string - WorkspaceSlug *string - Environment *string - SecretPath *string - Version *int - Type *string - ViewSecretValue *bool - ExpandSecretReferences *bool - IncludeImports *bool -} - -// listSecretsRawV3AsUser is a helper that lists secrets using V3 API as a user via HTTP. -func listSecretsRawV3AsUser(t *testing.T, userID, orgID string, params *ListSecretsV3Params) (secret.ListSecretsV4Response, error) { - t.Helper() - - handler := newSecretsHandler(t) - srv := newTestServer(t, handler, auth.ActorTypeUser, userID, orgID) - defer srv.Close() - - // Build URL - urlParams := url.Values{} - if params.WorkspaceID != nil { - urlParams.Set("workspaceId", *params.WorkspaceID) - } - if params.WorkspaceSlug != nil { - urlParams.Set("workspaceSlug", *params.WorkspaceSlug) - } - if params.Environment != nil { - urlParams.Set("environment", *params.Environment) - } - if params.SecretPath != nil { - urlParams.Set("secretPath", *params.SecretPath) - } - if params.ViewSecretValue != nil { - urlParams.Set("viewSecretValue", strconv.FormatBool(*params.ViewSecretValue)) - } - if params.ExpandSecretReferences != nil { - urlParams.Set("expandSecretReferences", strconv.FormatBool(*params.ExpandSecretReferences)) - } - if params.Recursive != nil { - urlParams.Set("recursive", strconv.FormatBool(*params.Recursive)) - } - if params.IncludeImports != nil { - urlParams.Set("include_imports", strconv.FormatBool(*params.IncludeImports)) - } - if params.TagSlugs != nil { - urlParams.Set("tagSlugs", *params.TagSlugs) - } - if params.MetadataFilter != nil { - urlParams.Set("metadataFilter", *params.MetadataFilter) - } - - path := fmt.Sprintf("/api/v3/secrets/raw?%s", urlParams.Encode()) - body, status := doGet(t, srv, path) - - if status >= 400 { - var errResp shared.Error - _ = json.Unmarshal(body, &errResp) - return secret.ListSecretsV4Response{}, errors.New(errResp.Message) - } - - var resp secret.ListSecretsV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - return resp, nil -} - -// ListSecretsV4Params holds parameters for V4 list secrets request. -type ListSecretsV4Params struct { - ProjectID string - Environment string - SecretPath *string - ViewSecretValue *bool - ExpandSecretReferences *bool - Recursive *bool - IncludePersonalOverrides *bool - IncludeImports *bool - TagSlugs *string - MetadataFilter *string -} - -// listSecrets is a helper that calls ListSecretsV4 via HTTP. -func listSecrets(t *testing.T, actorType auth.ActorType, actorID, orgID string, params *ListSecretsV4Params) (secret.ListSecretsV4Response, error) { - t.Helper() - - handler := newSecretsHandler(t) - srv := newTestServer(t, handler, actorType, actorID, orgID) - defer srv.Close() - - // Build URL - urlParams := url.Values{} - urlParams.Set("projectId", params.ProjectID) - urlParams.Set("environment", params.Environment) - if params.SecretPath != nil { - urlParams.Set("secretPath", *params.SecretPath) - } - if params.ViewSecretValue != nil { - urlParams.Set("viewSecretValue", strconv.FormatBool(*params.ViewSecretValue)) - } - if params.ExpandSecretReferences != nil { - urlParams.Set("expandSecretReferences", strconv.FormatBool(*params.ExpandSecretReferences)) - } - if params.Recursive != nil { - urlParams.Set("recursive", strconv.FormatBool(*params.Recursive)) - } - if params.IncludePersonalOverrides != nil { - urlParams.Set("includePersonalOverrides", strconv.FormatBool(*params.IncludePersonalOverrides)) - } - if params.IncludeImports != nil { - urlParams.Set("includeImports", strconv.FormatBool(*params.IncludeImports)) - } - if params.TagSlugs != nil { - urlParams.Set("tagSlugs", *params.TagSlugs) - } - if params.MetadataFilter != nil { - urlParams.Set("metadataFilter", *params.MetadataFilter) - } - - path := fmt.Sprintf("/api/v4/secrets?%s", urlParams.Encode()) - body, status := doGet(t, srv, path) - - if status >= 400 { - var errResp shared.Error - _ = json.Unmarshal(body, &errResp) - return secret.ListSecretsV4Response{}, errors.New(errResp.Message) - } - - var resp secret.ListSecretsV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - return resp, nil -} - -// GetSecretByNameV4Params holds parameters for V4 get secret request. -type GetSecretByNameV4Params struct { - ProjectID string - Environment string - SecretPath *string - Version *int - Type *string - ViewSecretValue *bool - ExpandSecretReferences *bool - IncludeImports *bool -} - -// getSecretByName is a helper that calls GetSecretByNameV4 via HTTP. -func getSecretByName(t *testing.T, actorType auth.ActorType, actorID, orgID, secretName string, params *GetSecretByNameV4Params) (secret.GetSecretByNameV4Response, error) { - t.Helper() - - handler := newSecretsHandler(t) - srv := newTestServer(t, handler, actorType, actorID, orgID) - defer srv.Close() - - // Build URL - urlParams := url.Values{} - urlParams.Set("projectId", params.ProjectID) - urlParams.Set("environment", params.Environment) - if params.SecretPath != nil { - urlParams.Set("secretPath", *params.SecretPath) - } - if params.Version != nil { - urlParams.Set("version", strconv.Itoa(*params.Version)) - } - if params.Type != nil { - urlParams.Set("type", *params.Type) - } - if params.ViewSecretValue != nil { - urlParams.Set("viewSecretValue", strconv.FormatBool(*params.ViewSecretValue)) - } - if params.ExpandSecretReferences != nil { - urlParams.Set("expandSecretReferences", strconv.FormatBool(*params.ExpandSecretReferences)) - } - if params.IncludeImports != nil { - urlParams.Set("includeImports", strconv.FormatBool(*params.IncludeImports)) - } - - path := fmt.Sprintf("/api/v4/secrets/%s?%s", url.PathEscape(secretName), urlParams.Encode()) - body, status := doGet(t, srv, path) - - if status >= 400 { - var errResp shared.Error - _ = json.Unmarshal(body, &errResp) - return secret.GetSecretByNameV4Response{}, errors.New(errResp.Message) - } - - var resp secret.GetSecretByNameV4Response - require.NoError(t, json.Unmarshal(body, &resp)) - return resp, nil -} - -// testAuthMiddleware injects a test identity into the request context. -func testAuthMiddleware(actorType auth.ActorType, actorID, orgID string) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := auth.WithIdentity(r.Context(), &auth.Identity{ - AuthMode: auth.AuthModeIdentityAccessToken, - Actor: actorType, - ActorID: uuid.MustParse(actorID), - OrgID: uuid.MustParse(orgID), - AuthMethod: "", - }) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} - -// newTestServer creates an HTTP test server with configurable actor type. -func newTestServer(t *testing.T, handler *secret.Handler, actorType auth.ActorType, actorID, orgID string) *httptest.Server { - t.Helper() - - router := secret.NewRouter( - handler, - secret.WithMiddleware(testAuthMiddleware(actorType, actorID, orgID)), - secret.WithErrorHandler(shared.NewErrorHandler(infra.NopLogger())), - ) - - return httptest.NewServer(router) -} diff --git a/backend-go/tests/secrets/secrets/get_secret_by_name_basic_test.go b/backend-go/tests/secrets/secrets/get_secret_by_name_basic_test.go new file mode 100644 index 00000000000..127d327125e --- /dev/null +++ b/backend-go/tests/secrets/secrets/get_secret_by_name_basic_test.go @@ -0,0 +1,285 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +func TestGetSecretByName_Basic(t *testing.T) { + tests := []struct { + name string + secretName string + path string // defaults to "/" + seed func(t *testing.T, api *nodejs.API, projectID, env string) + wantErr string // non-empty => expect an error containing this substring + assertResp func(t *testing.T, resp secret.GetSecretByNameV4Response) + }{ + { + name: "returns decrypted value", + secretName: "PLAIN_SECRET", + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, env, "PLAIN_SECRET", "plain-value").Do() + }, + assertResp: func(t *testing.T, resp secret.GetSecretByNameV4Response) { + assert.Equal(t, "plain-value", resp.Secret.SecretValue) + }, + }, + { + name: "returns comment", + secretName: "COMMENTED_SECRET", + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, env, "COMMENTED_SECRET", "commented-value"). + Comment("This is a comment for the secret").Do() + }, + assertResp: func(t *testing.T, resp secret.GetSecretByNameV4Response) { + assert.Equal(t, "This is a comment for the secret", resp.Secret.SecretComment) + }, + }, + { + name: "returns metadata", + secretName: "METADATA_SECRET", + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, env, "METADATA_SECRET", "metadata-value"). + Metadata( + nodejs.SecretMetadataEntry{Key: "env", Value: "production"}, + nodejs.SecretMetadataEntry{Key: "owner", Value: "platform-team"}, + ).Do() + }, + assertResp: func(t *testing.T, resp secret.GetSecretByNameV4Response) { + require.NotEmpty(t, resp.Secret.SecretMetadata) + metadataMap := make(map[string]string) + for _, m := range resp.Secret.SecretMetadata { + metadataMap[m.Key] = m.Value + } + assert.Equal(t, "production", metadataMap["env"]) + assert.Equal(t, "platform-team", metadataMap["owner"]) + }, + }, + { + name: "returns reminder", + secretName: "REMINDER_SECRET", + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, env, "REMINDER_SECRET", "reminder-value"). + Reminder("Remember to rotate this secret", 30).Do() + }, + assertResp: func(t *testing.T, resp secret.GetSecretByNameV4Response) { + require.NotNil(t, resp.Secret.SecretReminderNote) + assert.Equal(t, "Remember to rotate this secret", *resp.Secret.SecretReminderNote) + require.NotNil(t, resp.Secret.SecretReminderRepeatDays) + assert.Equal(t, 30, *resp.Secret.SecretReminderRepeatDays) + }, + }, + { + name: "returns tag with color", + secretName: "TAGGED_SECRET", + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + tag := api.Tags.Create(projectID, "important", "Important", "#ff0000") + api.Secrets.Create(projectID, env, "TAGGED_SECRET", "tagged-value").Tags(tag.ID).Do() + }, + assertResp: func(t *testing.T, resp secret.GetSecretByNameV4Response) { + require.Len(t, resp.Secret.Tags, 1) + assert.Equal(t, "important", resp.Secret.Tags[0].Slug) + require.NotNil(t, resp.Secret.Tags[0].Color) + assert.Equal(t, "#ff0000", *resp.Secret.Tags[0].Color) + }, + }, + { + name: "secretPath filters to nested folder", + secretName: "NESTED_GET_SECRET", + path: "/nested", + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Folders.Create(projectID, env, "/", "nested") + api.Secrets.Create(projectID, env, "NESTED_GET_SECRET", "nested-get-value").Path("/nested").Do() + }, + assertResp: func(t *testing.T, resp secret.GetSecretByNameV4Response) { + assert.Equal(t, "nested-get-value", resp.Secret.SecretValue) + }, + }, + { + name: "missing secret returns not found", + secretName: "NON_EXISTENT_SECRET", + wantErr: "not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-basic").Do() + if tc.seed != nil { + tc.seed(t, api, proj.ID, proj.EnvSlug) + } + + identity := api.Identities.Create("get-basic-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + path := tc.path + if path == "" { + path = "/" + } + + resp, err := getSecret(client, tc.secretName, &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new(path), + ViewSecretValue: new(true), + }) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.secretName, resp.Secret.SecretKey) + if tc.assertResp != nil { + tc.assertResp(t, resp) + } + }) + } +} + +// TestGetSecretByName_Validation covers required-param validation. These send +// omitted required params, which the typed query struct cannot express, so they +// build the request directly with raw params. +func TestGetSecretByName_Validation(t *testing.T) { + tests := []struct { + name string + includeProj bool + includeEnv bool + }{ + {name: "missing projectId returns 400", includeProj: false, includeEnv: true}, + {name: "missing environment returns 400", includeProj: true, includeEnv: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-validation").Do() + identity := api.Identities.Create("get-validation-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + req := client.Get("/api/v4/secrets/SOME_SECRET") + if tc.includeProj { + req.Param("projectId", proj.ID) + } + if tc.includeEnv { + req.Param("environment", proj.EnvSlug) + } + req.ExpectStatus(400) + }) + } +} + +// TestGetSecretByName_Version retrieves a specific historical version via the +// version query param after the secret has been updated. +func TestGetSecretByName_Version(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-version").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "VERSIONED", "v1-value").Do() + api.Secrets.Update(proj.ID, proj.EnvSlug, "VERSIONED", "v2-value").Do() + + identity := api.Identities.Create("get-version-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + t.Run("latest version by default", func(t *testing.T) { + resp, err := getSecret(client, "VERSIONED", &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + assert.Equal(t, "v2-value", resp.Secret.SecretValue) + assert.Equal(t, 2, resp.Secret.Version) + }) + + t.Run("specific older version", func(t *testing.T) { + resp, err := getSecret(client, "VERSIONED", &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + Version: new(1), + }) + require.NoError(t, err) + assert.Equal(t, "v1-value", resp.Secret.SecretValue) + assert.Equal(t, 1, resp.Secret.Version) + }) + + t.Run("nonexistent version errors", func(t *testing.T) { + _, err := getSecret(client, "VERSIONED", &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + Version: new(999), + }) + require.Error(t, err) + }) +} + +// TestGetSecretByName_ResponseFields covers the response fields not exercised by +// the core cases: skipMultilineEncoding, actor, and the rotation flags. A rotated +// secret requires secret rotation setup (no test infra yet); this verifies a +// normal secret is correctly reported as not rotated. +func TestGetSecretByName_ResponseFields(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-response-fields").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "MULTILINE", "line1\nline2").SkipMultilineEncoding().Do() + + identity := api.Identities.Create("get-response-fields-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := getSecret(client, "MULTILINE", &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + require.NotNil(t, resp.Secret.SkipMultilineEncoding, "skipMultilineEncoding should be present") + assert.True(t, *resp.Secret.SkipMultilineEncoding) + + require.NotNil(t, resp.Secret.IsRotatedSecret, "isRotatedSecret should be present") + assert.False(t, *resp.Secret.IsRotatedSecret, "a normal secret is not a rotated secret") + assert.Nil(t, resp.Secret.RotationID, "rotationId should be absent for a non-rotated secret") +} diff --git a/backend-go/tests/secrets/secrets/get_secret_by_name_expansion_test.go b/backend-go/tests/secrets/secrets/get_secret_by_name_expansion_test.go new file mode 100644 index 00000000000..ede5efd5fe2 --- /dev/null +++ b/backend-go/tests/secrets/secrets/get_secret_by_name_expansion_test.go @@ -0,0 +1,201 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +// The expansion algorithm itself (circular refs, self-reference, max depth, +// chained/nested absolute refs, import ordering) is exhaustively covered by the +// pure-function unit tests in internal/services/secrets/secret/expansion_test.go. +// These integration tests only cover what that unit test cannot: that expansion +// runs end-to-end through the handler, resolves against the real DB, is gated by +// live permissions, and feeds from imports. + +func TestGetSecretByName_Expansion(t *testing.T) { + tests := []struct { + name string + target string + expand bool + seed func(t *testing.T, api *nodejs.API, projectID, env string) + expected string + }{ + { + name: "expands same-folder reference", + target: "ENDPOINT", + expand: true, + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, env, "HOST", "myhost.com").Do() + api.Secrets.Create(projectID, env, "PORT", "5432").Do() + api.Secrets.Create(projectID, env, "ENDPOINT", "${HOST}:${PORT}").Do() + }, + expected: "myhost.com:5432", + }, + { + name: "preserves references when expansion disabled", + target: "ENDPOINT", + expand: false, + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, env, "HOST", "myhost.com").Do() + api.Secrets.Create(projectID, env, "ENDPOINT", "${HOST}:8080").Do() + }, + expected: "${HOST}:8080", + }, + { + name: "expands absolute cross-env reference against the database", + target: "CONN", + expand: true, + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Secrets.Create(projectID, "staging", "DB_HOST", "staging-db.example.com").Do() + api.Secrets.Create(projectID, env, "CONN", "${staging.DB_HOST}").Do() + }, + expected: "staging-db.example.com", + }, + { + name: "expands absolute cross-env reference into a nested path", + target: "CONN", + expand: true, + seed: func(t *testing.T, api *nodejs.API, projectID, env string) { + api.Folders.Create(projectID, "staging", "/", "db") + api.Folders.Create(projectID, "staging", "/db", "primary") + api.Secrets.Create(projectID, "staging", "DB_HOST", "primary-db.example.com").Path("/db/primary").Do() + // ${env.path.KEY}: dotted path segments map to /db/primary. + api.Secrets.Create(projectID, env, "CONN", "${staging.db.primary.DB_HOST}").Do() + }, + expected: "primary-db.example.com", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-expansion").Do() + tc.seed(t, api, proj.ID, proj.EnvSlug) + + identity := api.Identities.Create("get-expansion-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := getSecret(client, tc.target, &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + ExpandSecretReferences: new(tc.expand), + }) + + require.NoError(t, err) + assert.Equal(t, tc.target, resp.Secret.SecretKey) + assert.Equal(t, tc.expected, resp.Secret.SecretValue) + }) + } +} + +// TestGetSecretByName_Imports verifies imports feed the lookup end-to-end: an +// imported secret is resolvable only when imports are included. +func TestGetSecretByName_Imports(t *testing.T) { + tests := []struct { + name string + includeImports bool + wantErr string + wantValue string + wantSourceEnv string + }{ + { + name: "returns imported secret with source environment", + includeImports: true, + wantValue: "staging-value", + wantSourceEnv: "staging", + }, + { + name: "imported secret not found when excluded", + includeImports: false, + wantErr: "not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-import").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_SECRET", "staging-value").Do() + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + + identity := api.Identities.Create("get-import-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := getSecret(client, "STAGING_SECRET", &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(tc.includeImports), + }) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, "STAGING_SECRET", resp.Secret.SecretKey) + assert.Equal(t, tc.wantValue, resp.Secret.SecretValue) + assert.Equal(t, tc.wantSourceEnv, resp.Secret.Environment, "should return actual source environment") + }) + } +} + +// TestGetSecretByName_ExpansionThroughImports verifies a relative reference is +// resolved using a secret that exists only via an import. +func TestGetSecretByName_ExpansionThroughImports(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-expansion-import").Do() + // Base value lives in staging; dev imports staging and references it. + api.Secrets.Create(proj.ID, "staging", "DB_HOST", "staging-db.example.com").Do() + api.Secrets.Create(proj.ID, "dev", "DB_URL", "postgres://${DB_HOST}/app").Do() + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + + identity := api.Identities.Create("get-expansion-import-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := getSecret(client, "DB_URL", &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + ExpandSecretReferences: new(true), + IncludeImports: new(true), + }) + + require.NoError(t, err) + assert.Equal(t, "postgres://staging-db.example.com/app", resp.Secret.SecretValue) +} diff --git a/backend-go/tests/secrets/secrets/get_secret_by_name_permission_test.go b/backend-go/tests/secrets/secrets/get_secret_by_name_permission_test.go new file mode 100644 index 00000000000..57b18fa43e4 --- /dev/null +++ b/backend-go/tests/secrets/secrets/get_secret_by_name_permission_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +// TestGetSecretByName_ImportPermissions verifies that importing a secret across +// environments requires permission on the source environment, not just the +// importing one. The project, secrets, import, and identities are read-only +// fixtures shared across the cases. +func TestGetSecretByName_ImportPermissions(t *testing.T) { + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("get-import-perm").Do() + + api.Secrets.Create(proj.ID, "staging", "STAGING_SECRET", "staging-value").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_DIRECT", "dev-direct-value").Do() + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + + devOnlyRole := api.Roles.CreateCustom(proj.ID, "dev-only-reader", "Dev Only", nodejs.Permission{ + Subject: "secrets", + Action: []string{"read"}, + Conditions: map[string]any{"environment": "dev"}, + }) + devOnlyIdentity := api.Identities.Create("dev-only-identity") + api.Identities.AddToProject(proj.ID, devOnlyIdentity.ID).Role(devOnlyRole.Slug).Do() + + adminIdentity := api.Identities.Create("admin-identity") + api.Identities.AddToProject(proj.ID, adminIdentity.ID).Role("admin").Do() + + tests := []struct { + name string + identityID string + secretName string + includeImports bool + wantErr string + wantValue string + wantSourceEnv string + }{ + { + name: "direct secret allowed with env-scoped permission", + identityID: devOnlyIdentity.ID, + secretName: "DEV_DIRECT", + includeImports: false, + wantValue: "dev-direct-value", + }, + { + name: "imported secret denied without source env permission", + identityID: devOnlyIdentity.ID, + secretName: "STAGING_SECRET", + includeImports: true, + wantErr: "Permission", + }, + { + name: "imported secret allowed with admin permission", + identityID: adminIdentity.ID, + secretName: "STAGING_SECRET", + includeImports: true, + wantValue: "staging-value", + wantSourceEnv: "staging", + }, + { + name: "imported secret not found when imports excluded", + identityID: adminIdentity.ID, + secretName: "STAGING_SECRET", + includeImports: false, + wantErr: "not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(tc.identityID, nj.OrgID())). + Build() + + resp, err := getSecret(client, tc.secretName, &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(tc.includeImports), + }) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.secretName, resp.Secret.SecretKey) + assert.Equal(t, tc.wantValue, resp.Secret.SecretValue) + if tc.wantSourceEnv != "" { + assert.Equal(t, tc.wantSourceEnv, resp.Secret.Environment, "should return actual source environment") + } + }) + } +} diff --git a/backend-go/tests/secrets/secrets/list_secrets_basic_test.go b/backend-go/tests/secrets/secrets/list_secrets_basic_test.go new file mode 100644 index 00000000000..f3b0d4acb98 --- /dev/null +++ b/backend-go/tests/secrets/secrets/list_secrets_basic_test.go @@ -0,0 +1,716 @@ +//go:build integration + +package secrets_test + +import ( + "context" + "fmt" + "sort" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +func TestListSecrets_Basic(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-basic").Do() + + tag1 := api.Tags.Create(proj.ID, "env-prod", "Production", "#FF0000") + tag2 := api.Tags.Create(proj.ID, "sensitive", "Sensitive", "#0000FF") + + api.Secrets.Create(proj.ID, proj.EnvSlug, "PLAIN_SECRET", "plain-value").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "ENCRYPTED_SECRET", "decrypted-correctly").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "TAGGED_SECRET", "tagged-value").Tags(tag1.ID, tag2.ID).Do() + + identity := api.Identities.Create("list-basic-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 3) + + // Structure + decryption. + plain := findSecret(resp.Secrets, "PLAIN_SECRET") + require.NotNil(t, plain) + assert.NotEmpty(t, plain.ID) + assert.Equal(t, "PLAIN_SECRET", plain.SecretKey) + assert.Equal(t, "plain-value", plain.SecretValue) + assert.Equal(t, proj.EnvSlug, plain.Environment) + assert.NotEmpty(t, plain.Workspace) + assert.NotEmpty(t, plain.CreatedAt) + assert.NotEmpty(t, plain.UpdatedAt) + assert.Equal(t, 1, plain.Version) + assert.Equal(t, secret.Shared, plain.Type) + + encrypted := findSecret(resp.Secrets, "ENCRYPTED_SECRET") + require.NotNil(t, encrypted) + assert.Equal(t, "decrypted-correctly", encrypted.SecretValue) + + // Tags. + tagged := findSecret(resp.Secrets, "TAGGED_SECRET") + require.NotNil(t, tagged) + require.Len(t, tagged.Tags, 2) + tagSlugs := make([]string, len(tagged.Tags)) + for i, tag := range tagged.Tags { + tagSlugs[i] = tag.Slug + } + assert.Contains(t, tagSlugs, "env-prod") + assert.Contains(t, tagSlugs, "sensitive") +} + +func TestListSecrets_ReturnsComment(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-comment").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "SECRET_WITH_COMMENT", "secret-value"). + Comment("This is a test comment for the secret").Do() + + identity := api.Identities.Create("list-comment-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "SECRET_WITH_COMMENT", resp.Secrets[0].SecretKey) + assert.Equal(t, "secret-value", resp.Secrets[0].SecretValue) + assert.Equal(t, "This is a test comment for the secret", resp.Secrets[0].SecretComment) +} + +func TestListSecrets_Metadata(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-metadata").Do() + + api.Secrets.Create(proj.ID, proj.EnvSlug, "SECRET_WITH_METADATA", "secret-value"). + Metadata( + nodejs.SecretMetadataEntry{Key: "owner", Value: "platform-team"}, + nodejs.SecretMetadataEntry{Key: "sensitivity", Value: "high"}, + ).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "SECRET_WITH_COMMENT", "full-value"). + Comment("A secret with both comment and metadata"). + Metadata( + nodejs.SecretMetadataEntry{Key: "env", Value: "production"}, + ).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "SECRET_MIXED_ENCRYPTION", "encrypted-meta-value"). + Metadata( + nodejs.SecretMetadataEntry{Key: "plaintext", Value: "plain-value", IsEncrypted: false}, + nodejs.SecretMetadataEntry{Key: "sensitive", Value: "encrypted-value", IsEncrypted: true}, + ).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "PROD_SECRET", "prod-value"). + Metadata(nodejs.SecretMetadataEntry{Key: "env", Value: "production"}).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "DEV_SECRET", "dev-value"). + Metadata(nodejs.SecretMetadataEntry{Key: "env", Value: "development"}).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "NO_METADATA", "no-meta-value").Do() + + identity := api.Identities.Create("list-metadata-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + t.Run("returns metadata fields", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + secretItem := findSecret(resp.Secrets, "SECRET_WITH_METADATA") + require.NotNil(t, secretItem) + require.Len(t, secretItem.SecretMetadata, 2) + metadataMap := make(map[string]string) + for _, m := range secretItem.SecretMetadata { + metadataMap[m.Key] = m.Value + } + assert.Equal(t, "platform-team", metadataMap["owner"]) + assert.Equal(t, "high", metadataMap["sensitivity"]) + }) + + t.Run("comment and metadata together", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + secretItem := findSecret(resp.Secrets, "SECRET_WITH_COMMENT") + require.NotNil(t, secretItem) + assert.Equal(t, "full-value", secretItem.SecretValue) + assert.Equal(t, "A secret with both comment and metadata", secretItem.SecretComment) + require.Len(t, secretItem.SecretMetadata, 1) + assert.Equal(t, "env", secretItem.SecretMetadata[0].Key) + assert.Equal(t, "production", secretItem.SecretMetadata[0].Value) + }) + + t.Run("encrypted vs plaintext metadata", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + secretItem := findSecret(resp.Secrets, "SECRET_MIXED_ENCRYPTION") + require.NotNil(t, secretItem) + require.Len(t, secretItem.SecretMetadata, 2) + + metadataMap := make(map[string]*secret.ResourceMetadata) + for i := range secretItem.SecretMetadata { + m := &secretItem.SecretMetadata[i] + metadataMap[m.Key] = m + } + + plaintext := metadataMap["plaintext"] + require.NotNil(t, plaintext) + assert.Equal(t, "plain-value", plaintext.Value) + assert.False(t, plaintext.IsEncrypted) + + sensitive := metadataMap["sensitive"] + require.NotNil(t, sensitive) + assert.Equal(t, "encrypted-value", sensitive.Value) + assert.True(t, sensitive.IsEncrypted) + }) + + t.Run("filter by metadata", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + MetadataFilter: new("key=env,value=production"), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 2) + + keys := make([]string, len(resp.Secrets)) + for i, s := range resp.Secrets { + keys[i] = s.SecretKey + } + assert.Contains(t, keys, "PROD_SECRET") + assert.Contains(t, keys, "SECRET_WITH_COMMENT") + }) +} + +func TestListSecrets_Reminder(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-reminder").Do() + + api.Secrets.Create(proj.ID, proj.EnvSlug, "SECRET_WITH_REMINDER", "reminder-value"). + Reminder("Rotate weekly", 7).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "SECRET_WITHOUT_REMINDER", "no-reminder-value").Do() + + identity := api.Identities.Create("list-reminder-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 2) + + withReminder := findSecret(resp.Secrets, "SECRET_WITH_REMINDER") + require.NotNil(t, withReminder) + require.NotNil(t, withReminder.SecretReminderNote) + assert.Equal(t, "Rotate weekly", *withReminder.SecretReminderNote) + require.NotNil(t, withReminder.SecretReminderRepeatDays) + assert.Equal(t, 7, *withReminder.SecretReminderRepeatDays) + + withoutReminder := findSecret(resp.Secrets, "SECRET_WITHOUT_REMINDER") + require.NotNil(t, withoutReminder) + assert.Nil(t, withoutReminder.SecretReminderNote) + assert.Nil(t, withoutReminder.SecretReminderRepeatDays) +} + +func TestListSecrets_PathAndRecursive(t *testing.T) { + tests := []struct { + name string + path string + recursive *bool + wantKeys []string + }{ + { + name: "recursive includes subfolders", + path: "/", + recursive: new(true), + wantKeys: []string{"ROOT_SECRET", "LEVEL1_SECRET", "LEVEL2_SECRET", "API_SECRET", "WEB_SECRET"}, + }, + { + name: "non-recursive only current folder", + path: "/", + recursive: new(false), + wantKeys: []string{"ROOT_SECRET"}, + }, + { + name: "specific path", + path: "/api", + wantKeys: []string{"API_SECRET"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-path-recursive").Do() + + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "level1") + api.Folders.Create(proj.ID, proj.EnvSlug, "/level1", "level2") + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "api") + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "web") + + api.Secrets.Create(proj.ID, proj.EnvSlug, "ROOT_SECRET", "root-value").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "LEVEL1_SECRET", "level1-value").Path("/level1").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "LEVEL2_SECRET", "level2-value").Path("/level1/level2").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "API_SECRET", "api-value").Path("/api").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "WEB_SECRET", "web-value").Path("/web").Do() + + identity := api.Identities.Create("list-path-recursive-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new(tc.path), + ViewSecretValue: new(true), + Recursive: tc.recursive, + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, len(tc.wantKeys)) + + keys := make([]string, len(resp.Secrets)) + for i, s := range resp.Secrets { + keys[i] = s.SecretKey + } + assert.ElementsMatch(t, tc.wantKeys, keys) + }) + } +} + +func TestListSecrets_PersonalOverrides(t *testing.T) { + tests := []struct { + name string + asUser bool // user has the personal override; identity does not + includeOverrides bool + wantValue string + }{ + { + name: "user without overrides sees shared", + asUser: true, + includeOverrides: false, + wantValue: "shared-value", + }, + { + name: "user with overrides sees personal", + asUser: true, + includeOverrides: true, + wantValue: "personal-value", + }, + { + name: "identity always sees shared", + asUser: false, + includeOverrides: true, + wantValue: "shared-value", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-personal").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "MY_SECRET", "shared-value").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "MY_SECRET", "personal-value").Personal().Do() + + identity := api.Identities.Create("list-personal-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + actor := infra.MachineIdentity(identity.ID, nj.OrgID()) + if tc.asUser { + actor = infra.UserIdentity(nj.UserID(), nj.OrgID()) + } + client := infra.NewClientBuilder(t, newSecretsRouter(t)).Identity(actor).Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludePersonalOverrides: new(tc.includeOverrides), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "MY_SECRET", resp.Secrets[0].SecretKey) + assert.Equal(t, tc.wantValue, resp.Secrets[0].SecretValue) + }) + } +} + +func TestListSecrets_TagFiltering(t *testing.T) { + tests := []struct { + name string + tagSlugs string + wantKeys []string + }{ + {name: "single tag", tagSlugs: "api", wantKeys: []string{"API_KEY"}}, + {name: "multiple tags", tagSlugs: "api,database", wantKeys: []string{"API_KEY", "DB_PASSWORD"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-tag-filter").Do() + tag1 := api.Tags.Create(proj.ID, "api", "API", "#FF0000") + tag2 := api.Tags.Create(proj.ID, "database", "Database", "#00FF00") + + api.Secrets.Create(proj.ID, proj.EnvSlug, "API_KEY", "api-key-value").Tags(tag1.ID).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "DB_PASSWORD", "db-password").Tags(tag2.ID).Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "UNTAGGED_SECRET", "untagged-value").Do() + + identity := api.Identities.Create("list-tag-filter-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + TagSlugs: new(tc.tagSlugs), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, len(tc.wantKeys)) + + keys := make([]string, len(resp.Secrets)) + for i, s := range resp.Secrets { + keys[i] = s.SecretKey + } + assert.ElementsMatch(t, tc.wantKeys, keys) + }) + } +} + +func TestListSecrets_Errors(t *testing.T) { + tests := []struct { + name string + environment string + path string + wantErr string + }{ + {name: "environment not found", environment: "nonexistent", path: "/", wantErr: "not found"}, + {name: "folder not found", environment: "dev", path: "/nonexistent/path", wantErr: "not found"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-errors").Do() + identity := api.Identities.Create("list-errors-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + _, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: tc.environment, + SecretPath: new(tc.path), + ViewSecretValue: new(true), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// TestListSecrets_Validation covers required-param validation, which the typed +// query struct cannot express, so the request is built with raw params. +func TestListSecrets_Validation(t *testing.T) { + tests := []struct { + name string + includeProj bool + includeEnv bool + }{ + {name: "missing projectId returns 400", includeProj: false, includeEnv: true}, + {name: "missing environment returns 400", includeProj: true, includeEnv: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-validation").Do() + identity := api.Identities.Create("list-validation-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + req := client.Get("/api/v4/secrets") + if tc.includeProj { + req.Param("projectId", proj.ID) + } + if tc.includeEnv { + req.Param("environment", proj.EnvSlug) + } + req.ExpectStatus(400) + }) + } +} + +// TestListSecrets_SoftDeletedEnvironment is intentionally sequential: it asserts +// behavior before and after a soft delete on the same environment, so the steps +// cannot be parallelized. +func TestListSecrets_SoftDeletedEnvironment(t *testing.T) { + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-soft-delete-env").Do() + + customEnv := api.Environments.Create(proj.ID, "custom-env", "Custom Environment") + + api.Secrets.Create(proj.ID, "custom-env", "CUSTOM_SECRET", "custom-value").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "DEV_SECRET", "dev-value").Do() + + identity := api.Identities.Create("list-soft-delete-env-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + t.Run("secrets accessible before soft delete", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "custom-env", + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "CUSTOM_SECRET", resp.Secrets[0].SecretKey) + }) + + api.Environments.SoftDelete(proj.ID, customEnv.ID) + + t.Run("environment row still exists with softDeletedAt set", func(t *testing.T) { + var softDeletedAt *time.Time + err := stack.DB().Replica().QueryRow(context.Background(), ` + SELECT "softDeletedAt" FROM project_environments WHERE id = @envID + `, pgx.NamedArgs{"envID": customEnv.ID}).Scan(&softDeletedAt) + + require.NoError(t, err, "environment row should still exist in database") + require.NotNil(t, softDeletedAt, "softDeletedAt should be set (not NULL)") + }) + + t.Run("soft deleted environment returns not found", func(t *testing.T) { + _, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "custom-env", + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("other environments still work after soft delete", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "DEV_SECRET", resp.Secrets[0].SecretKey) + }) +} + +// TestListSecrets_StableOrdering verifies the response is ordered by key ascending +// regardless of creation order. +func TestListSecrets_StableOrdering(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-ordering").Do() + // Create in deliberately non-alphabetical order. + for _, key := range []string{"ZEBRA", "ALPHA", "MIKE", "BRAVO"} { + api.Secrets.Create(proj.ID, proj.EnvSlug, key, "v").Do() + } + + identity := api.Identities.Create("list-ordering-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + keys := make([]string, len(resp.Secrets)) + for i, s := range resp.Secrets { + keys[i] = s.SecretKey + } + + expected := append([]string(nil), keys...) + sort.Strings(expected) + assert.Equal(t, expected, keys, "secrets should be ordered by key ascending") +} + +// TestListSecrets_LargeResponse verifies a large folder returns every secret. +func TestListSecrets_LargeResponse(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-large").Do() + + const count = 60 + for i := 0; i < count; i++ { + api.Secrets.Create(proj.ID, proj.EnvSlug, fmt.Sprintf("KEY_%03d", i), fmt.Sprintf("value-%03d", i)).Do() + } + + identity := api.Identities.Create("list-large-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + assert.Len(t, resp.Secrets, count) +} + +// TestListSecrets_ContentType verifies the success response is JSON. +func TestListSecrets_ContentType(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-content-type").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "KEY", "value").Do() + + identity := api.Identities.Create("list-content-type-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + _, status, header := listRaw(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }, nil) + + require.Equal(t, 200, status) + assert.Contains(t, header.Get("Content-Type"), "application/json") +} + +// TestListSecrets_InvalidProjectID verifies a non-existent/invalid project id is +// rejected rather than silently returning data. +func TestListSecrets_InvalidProjectID(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-invalid-project").Do() + identity := api.Identities.Create("list-invalid-project-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + _, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: "not-a-valid-uuid", + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.Error(t, err) +} diff --git a/backend-go/tests/secrets/secrets/list_secrets_cache_test.go b/backend-go/tests/secrets/secrets/list_secrets_cache_test.go new file mode 100644 index 00000000000..c83b7af482c --- /dev/null +++ b/backend-go/tests/secrets/secrets/list_secrets_cache_test.go @@ -0,0 +1,448 @@ +//go:build integration + +package secrets_test + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +// listRaw issues a list request with optional headers and returns the raw +// status, body, and response headers. Cache/ETag tests need access to status +// codes and the ETag header, which the typed helpers hide. +func listRaw(client *infra.HTTPClient, q *secret.ListSecretsV4Query, headers map[string]string) (body []byte, status int, header http.Header) { + req := client.Get("/api/v4/secrets").Params(q) + for k, v := range headers { + req.Header(k, v) + } + return req.Do() +} + +func TestListSecrets_ETag_ReturnsHeader(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("etag-header").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "ETAG_SECRET", "etag-value").Do() + + identity := api.Identities.Create("etag-header-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + body, status, header := listRaw(client, &q, nil) + require.Equal(t, 200, status) + + etag := header.Get("ETag") + require.NotEmpty(t, etag, "ETag header should be present") + assert.Equal(t, byte('"'), etag[0], "ETag should be quoted") + assert.Equal(t, byte('"'), etag[len(etag)-1], "ETag should be quoted") + + var resp secret.ListSecretsV4Response + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "ETAG_SECRET", resp.Secrets[0].SecretKey) +} + +func TestListSecrets_ETag_Returns304WhenMatches(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("etag-304").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CACHE_SECRET", "cache-value").Do() + + identity := api.Identities.Create("etag-304-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + _, status, header := listRaw(client, &q, nil) + require.Equal(t, 200, status) + etag := header.Get("ETag") + require.NotEmpty(t, etag) + + body, status, header := listRaw(client, &q, map[string]string{"If-None-Match": etag}) + assert.Equal(t, 304, status) + assert.Empty(t, body) + assert.Equal(t, etag, header.Get("ETag")) +} + +func TestListSecrets_ETag_Returns200WhenDiffers(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("etag-miss").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "MISS_SECRET", "miss-value").Do() + + identity := api.Identities.Create("etag-miss-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + body, status, _ := listRaw(client, &q, map[string]string{"If-None-Match": `"invalid-etag"`}) + assert.Equal(t, 200, status) + assert.NotEmpty(t, body) + + var resp secret.ListSecretsV4Response + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "MISS_SECRET", resp.Secrets[0].SecretKey) +} + +func TestListSecrets_ETag_ConsistentForSameContent(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("etag-consistent").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CONSISTENT_SECRET", "consistent-value").Do() + + identity := api.Identities.Create("etag-consistent-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + _, status, header := listRaw(client, &q, nil) + require.Equal(t, 200, status) + firstEtag := header.Get("ETag") + + _, status, header = listRaw(client, &q, nil) + require.Equal(t, 200, status) + secondEtag := header.Get("ETag") + + assert.Equal(t, firstEtag, secondEtag) +} + +func TestListSecrets_ETag_DiffersForDifferentParams(t *testing.T) { + tests := []struct { + name string + setupSecond func(t *testing.T, api *nodejs.API, proj *nodejs.ProjectSeed) secret.ListSecretsV4Query + }{ + { + name: "different environment", + setupSecond: func(t *testing.T, api *nodejs.API, proj *nodejs.ProjectSeed) secret.ListSecretsV4Query { + api.Environments.Create(proj.ID, "custom-env", "Custom Env") + api.Secrets.Create(proj.ID, "custom-env", "CUSTOM_SECRET", "custom-value").Do() + return secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "custom-env", SecretPath: new("/"), ViewSecretValue: new(true), + } + }, + }, + { + name: "different path", + setupSecond: func(t *testing.T, api *nodejs.API, proj *nodejs.ProjectSeed) secret.ListSecretsV4Query { + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "config") + api.Secrets.Create(proj.ID, proj.EnvSlug, "CONFIG_SECRET", "config-value").Path("/config").Do() + return secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: proj.EnvSlug, SecretPath: new("/config"), ViewSecretValue: new(true), + } + }, + }, + { + name: "recursive vs non-recursive", + setupSecond: func(t *testing.T, api *nodejs.API, proj *nodejs.ProjectSeed) secret.ListSecretsV4Query { + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "nested") + api.Secrets.Create(proj.ID, proj.EnvSlug, "NESTED_SECRET", "nested-value").Path("/nested").Do() + return secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: proj.EnvSlug, SecretPath: new("/"), ViewSecretValue: new(true), Recursive: new(true), + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("etag-diff-params").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "ROOT_SECRET", "root-value").Do() + secondQuery := tc.setupSecond(t, api, proj) + + identity := api.Identities.Create("etag-diff-params-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + _, status, header := listRaw(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: proj.EnvSlug, SecretPath: new("/"), ViewSecretValue: new(true), Recursive: new(false), + }, nil) + require.Equal(t, 200, status) + firstEtag := header.Get("ETag") + require.NotEmpty(t, firstEtag) + + _, status, header = listRaw(client, &secondQuery, nil) + require.Equal(t, 200, status) + secondEtag := header.Get("ETag") + + assert.NotEqual(t, firstEtag, secondEtag) + }) + } +} + +func TestListSecrets_Cache_StoresInRedis(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + redisClient := stack.Redis().Client() + t.Cleanup(func() { redisClient.Close() }) + + proj := api.Projects.Create("cache-redis").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CACHED_SECRET", "cached-value").Do() + + identity := api.Identities.Create("cache-redis-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + etagKeyPattern := "secret-etag:" + proj.ID + ":*" + keysBefore, err := redisClient.Keys(context.Background(), etagKeyPattern).Result() + require.NoError(t, err) + + body, status, _ := listRaw(client, &q, nil) + require.Equal(t, 200, status) + var first secret.ListSecretsV4Response + require.NoError(t, json.Unmarshal(body, &first)) + require.Len(t, first.Secrets, 1) + + keysAfter, err := redisClient.Keys(context.Background(), etagKeyPattern).Result() + require.NoError(t, err) + assert.Greater(t, len(keysAfter), len(keysBefore), "ETag should be stored in Redis after first request") + + body, status, _ = listRaw(client, &q, nil) + require.Equal(t, 200, status) + var second secret.ListSecretsV4Response + require.NoError(t, json.Unmarshal(body, &second)) + require.Len(t, second.Secrets, 1) + assert.Equal(t, first.Secrets[0].SecretKey, second.Secrets[0].SecretKey) + assert.Equal(t, first.Secrets[0].SecretValue, second.Secrets[0].SecretValue) +} + +func TestListSecrets_Cache_IsolatedByActor(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("cache-actor").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "ACTOR_SECRET", "actor-value").Do() + + identity1 := api.Identities.Create("cache-actor-identity-1") + api.Identities.AddToProject(proj.ID, identity1.ID).Role("admin").Do() + + identity2 := api.Identities.Create("cache-actor-identity-2") + api.Identities.AddToProject(proj.ID, identity2.ID).Role("admin").Do() + + // Both clients share one router/handler so the cache state is shared; only + // the actor differs. + router := newSecretsRouter(t) + client1 := infra.NewClientBuilder(t, router).Identity(infra.MachineIdentity(identity1.ID, nj.OrgID())).Build() + client2 := infra.NewClientBuilder(t, router).Identity(infra.MachineIdentity(identity2.ID, nj.OrgID())).Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + _, status, header := listRaw(client1, &q, nil) + require.Equal(t, 200, status) + etag1 := header.Get("ETag") + + body2, status2, _ := listRaw(client2, &q, map[string]string{"If-None-Match": etag1}) + assert.Equal(t, 200, status2, "different actor should not get 304 from first actor's ETag") + assert.NotEmpty(t, body2) +} + +func TestListSecrets_Cache_Returns304FromHandlerCache(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("cache-handler-304").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "HANDLER_SECRET", "handler-value").Do() + + identity := api.Identities.Create("cache-handler-304-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + // One client => one handler, so the second request hits the same in-process + // cache the first populated. + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + _, status, header := listRaw(client, &q, nil) + require.Equal(t, 200, status) + handlerEtag := header.Get("ETag") + require.NotEmpty(t, handlerEtag) + + body, status, _ := listRaw(client, &q, map[string]string{"If-None-Match": handlerEtag}) + assert.Equal(t, 304, status) + assert.Empty(t, body) +} + +func TestListSecrets_Cache_InvalidatedOnSecretChange(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("cache-invalidate").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CHANGING_SECRET", "original-value").Do() + + identity := api.Identities.Create("cache-invalidate-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + body, status, header := listRaw(client, &q, nil) + require.Equal(t, 200, status) + firstEtag := header.Get("ETag") + var first secret.ListSecretsV4Response + require.NoError(t, json.Unmarshal(body, &first)) + assert.Equal(t, "original-value", first.Secrets[0].SecretValue) + + // Updating the secret increments the DAL version and deletes the ETag key, + // invalidating the cache. + api.Secrets.Update(proj.ID, proj.EnvSlug, "CHANGING_SECRET", "updated-value").Do() + + body, status, header = listRaw(client, &q, map[string]string{"If-None-Match": firstEtag}) + require.Equal(t, 200, status, "should return 200 after secret change, not 304") + + var second secret.ListSecretsV4Response + require.NoError(t, json.Unmarshal(body, &second)) + assert.Equal(t, "updated-value", second.Secrets[0].SecretValue) + assert.NotEqual(t, firstEtag, header.Get("ETag"), "ETag should change after secret update") +} + +// TestListSecrets_Cache_ConcurrentAccess hammers the same handler/cache from many +// goroutines. It guards against data races (run with -race) and confirms every +// concurrent reader gets a consistent, correct response. +func TestListSecrets_Cache_ConcurrentAccess(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("cache-concurrent").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CONCURRENT_SECRET", "concurrent-value").Do() + + identity := api.Identities.Create("cache-concurrent-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + q := secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + } + + const workers = 16 + var wg sync.WaitGroup + statuses := make([]int, workers) + values := make([]string, workers) + errs := make([]error, workers) + + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + body, status, _ := listRaw(client, &q, nil) + statuses[i] = status + var resp secret.ListSecretsV4Response + if err := json.Unmarshal(body, &resp); err != nil { + errs[i] = err + return + } + if len(resp.Secrets) == 1 { + values[i] = resp.Secrets[0].SecretValue + } + }(i) + } + wg.Wait() + + for i := 0; i < workers; i++ { + require.NoError(t, errs[i]) + assert.Equal(t, 200, statuses[i]) + assert.Equal(t, "concurrent-value", values[i]) + } +} diff --git a/backend-go/tests/secrets/secrets/list_secrets_expansion_test.go b/backend-go/tests/secrets/secrets/list_secrets_expansion_test.go new file mode 100644 index 00000000000..c400aebc750 --- /dev/null +++ b/backend-go/tests/secrets/secrets/list_secrets_expansion_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" +) + +// The expansion algorithm (circular refs, self-reference, max depth, chained +// absolute refs, import ordering) is covered by the unit tests in +// internal/services/secrets/secret/expansion_test.go. These integration tests +// cover the end-to-end wiring: expansion and imports resolved through the real +// handler, DB, and permission layer for a full list response. + +// TestListSecrets_Imports and the import-response structure tests live in +// list_secrets_imports_test.go. + +func TestListSecrets_Expansion(t *testing.T) { + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-expansion").Do() + api.Environments.Create(proj.ID, "shared", "Shared") + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "common") + + api.Secrets.Create(proj.ID, proj.EnvSlug, "HOST", "myhost.com").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "PORT", "5432").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "ENDPOINT", "${HOST}:${PORT}").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "FULL_URL", "https://${ENDPOINT}/api").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "BASE_VALUE", "base").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "REF_VALUE", "${BASE_VALUE}").Do() + + api.Secrets.Create(proj.ID, "shared", "SHARED_API_KEY", "shared-api-key-value").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CROSS_ENV_REF", "${shared.SHARED_API_KEY}").Do() + + api.Secrets.Create(proj.ID, proj.EnvSlug, "COMMON_SECRET", "common-value").Path("/common").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CROSS_PATH_REF", "${dev.common.COMMON_SECRET}").Do() + + identity := api.Identities.Create("list-expansion-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + expanded := func(t *testing.T) []secret.SecretRaw { + t.Helper() + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + ExpandSecretReferences: new(true), + }) + require.NoError(t, err) + return resp.Secrets + } + + t.Run("nested expansion", func(t *testing.T) { + secrets := expanded(t) + + endpoint := findSecret(secrets, "ENDPOINT") + require.NotNil(t, endpoint) + assert.Equal(t, "myhost.com:5432", endpoint.SecretValue) + + fullURL := findSecret(secrets, "FULL_URL") + require.NotNil(t, fullURL) + assert.Equal(t, "https://myhost.com:5432/api", fullURL.SecretValue) + }) + + t.Run("cross environment expansion", func(t *testing.T) { + secretItem := findSecret(expanded(t), "CROSS_ENV_REF") + require.NotNil(t, secretItem) + assert.Equal(t, "shared-api-key-value", secretItem.SecretValue) + }) + + t.Run("cross path expansion", func(t *testing.T) { + secretItem := findSecret(expanded(t), "CROSS_PATH_REF") + require.NotNil(t, secretItem) + assert.Equal(t, "common-value", secretItem.SecretValue) + }) + + t.Run("no expansion preserves references", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + ExpandSecretReferences: new(false), + }) + require.NoError(t, err) + + secretItem := findSecret(resp.Secrets, "REF_VALUE") + require.NotNil(t, secretItem) + assert.Equal(t, "${BASE_VALUE}", secretItem.SecretValue, "reference should NOT be expanded") + }) +} + +// TestListSecrets_ExpansionWithImports exercises expansion that resolves through +// multiple imports, including import priority (last import wins), local override +// of imports, and folder-level imports. +func TestListSecrets_ExpansionWithImports(t *testing.T) { + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-expansion-imports").Do() + + api.Folders.Create(proj.ID, "prod", "/", "config") + api.Folders.Create(proj.ID, "staging", "/", "services") + api.Folders.Create(proj.ID, "dev", "/", "app") + + api.Secrets.Create(proj.ID, "prod", "PROD_ROOT", "prod-root").Do() + api.Secrets.Create(proj.ID, "prod", "PROD_DB_HOST", "prod-db.example.com").Path("/config").Do() + api.Secrets.Create(proj.ID, "prod", "SHARED_KEY", "prod-shared-value").Path("/config").Do() + + api.Secrets.Create(proj.ID, "staging", "STAGING_API_URL", "https://staging-api.example.com").Do() + api.Secrets.Create(proj.ID, "staging", "SHARED_KEY", "staging-shared-value").Do() + api.Secrets.Create(proj.ID, "staging", "IMPORT_PRIORITY_KEY", "from-first-import").Do() + api.Secrets.Create(proj.ID, "staging", "SERVICE_URL", "https://staging-service.example.com").Path("/services").Do() + api.Secrets.Create(proj.ID, "staging", "IMPORT_PRIORITY_KEY", "from-second-import").Path("/services").Do() + api.Imports.Create(proj.ID, "staging", "/", "prod", "/config").Do() + + api.Secrets.Create(proj.ID, "dev", "LOCAL_SECRET", "local-only").Do() + api.Secrets.Create(proj.ID, "dev", "SHARED_KEY", "dev-shared-value").Do() + api.Secrets.Create(proj.ID, "dev", "APP_CONFIG", "app-config").Path("/app").Do() + + api.Secrets.Create(proj.ID, "dev", "REF_LOCAL", "${LOCAL_SECRET}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_STAGING", "${STAGING_API_URL}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_SHARED", "${SHARED_KEY}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_SERVICE", "${SERVICE_URL}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_PROD_VIA_STAGING", "${PROD_DB_HOST}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_CHAIN", "host=${PROD_DB_HOST}&api=${STAGING_API_URL}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_MISSING", "${NOT_EXISTS}").Do() + api.Secrets.Create(proj.ID, "dev", "REF_IMPORT_PRIORITY", "${IMPORT_PRIORITY_KEY}").Do() + + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + api.Imports.Create(proj.ID, "dev", "/", "staging", "/services").Do() + api.Imports.Create(proj.ID, "dev", "/app", "prod", "/config").Do() + + api.Secrets.Create(proj.ID, "dev", "APP_DB_URL", "postgres://${PROD_DB_HOST}:5432/app").Path("/app").Do() + + identity := api.Identities.Create("list-expansion-imports-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + t.Run("root level expansion", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(true), + ExpandSecretReferences: new(true), + }) + require.NoError(t, err) + + secretValues := make(map[string]string) + for _, s := range resp.Secrets { + secretValues[s.SecretKey] = s.SecretValue + } + + assert.Equal(t, "local-only", secretValues["REF_LOCAL"], "should expand from local") + assert.Equal(t, "https://staging-api.example.com", secretValues["REF_STAGING"], "should expand from staging import") + assert.Equal(t, "dev-shared-value", secretValues["REF_SHARED"], "local should override imports") + assert.Equal(t, "from-second-import", secretValues["REF_IMPORT_PRIORITY"], "last import should win over first import") + assert.Equal(t, "https://staging-service.example.com", secretValues["REF_SERVICE"], "should expand from staging/services import") + assert.Equal(t, "prod-db.example.com", secretValues["REF_PROD_VIA_STAGING"], "should expand from prod via staging import") + assert.Equal(t, "host=prod-db.example.com&api=https://staging-api.example.com", secretValues["REF_CHAIN"], "multiple refs should expand") + assert.Empty(t, secretValues["REF_MISSING"], "missing ref should be empty") + assert.GreaterOrEqual(t, len(resp.Imports), 2, "should have multiple imports") + }) + + t.Run("folder level expansion with folder import", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/app"), + ViewSecretValue: new(true), + IncludeImports: new(true), + ExpandSecretReferences: new(true), + }) + require.NoError(t, err) + + secretValues := make(map[string]string) + for _, s := range resp.Secrets { + secretValues[s.SecretKey] = s.SecretValue + } + + assert.Equal(t, "app-config", secretValues["APP_CONFIG"], "direct secret should be present") + assert.Equal(t, "postgres://prod-db.example.com:5432/app", secretValues["APP_DB_URL"], + "should expand using folder-level import from prod/config") + + require.Len(t, resp.Imports, 1, "should have 1 folder import") + assert.Equal(t, "prod", resp.Imports[0].Environment) + assert.Equal(t, "/config", resp.Imports[0].SecretPath) + }) +} diff --git a/backend-go/tests/secrets/secrets/list_secrets_imports_test.go b/backend-go/tests/secrets/secrets/list_secrets_imports_test.go new file mode 100644 index 00000000000..ee79138bc80 --- /dev/null +++ b/backend-go/tests/secrets/secrets/list_secrets_imports_test.go @@ -0,0 +1,327 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +// These tests pin the import-response contract: `imports` lists only the +// directly-configured (top-level) imports of the requested folder, and each +// top-level entry's `secrets` array contains the secrets resolved recursively +// from its own nested imports (flattened into the top-level entry). The chain +// resolver dedupes by (environment, path), so a source reachable more than once +// appears only once and cycles terminate. Reference expansion across imports is +// covered separately in list_secrets_expansion_test.go. + +// importSecretKeys collects the secret keys of a single top-level import entry. +func importSecretKeys(imp secret.SecretImport) []string { + keys := make([]string, len(imp.Secrets)) + for i := range imp.Secrets { + keys[i] = imp.Secrets[i].SecretKey + } + return keys +} + +func TestListSecrets_Imports(t *testing.T) { + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-imports").Do() + + api.Secrets.Create(proj.ID, "staging", "STAGING_DB_URL", "staging-db-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_API_KEY", "staging-api-value").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_SECRET", "dev-value").Do() + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + + identity := api.Identities.Create("list-imports-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + t.Run("include imports returns imported secrets", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(true), + }) + require.NoError(t, err) + + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "DEV_SECRET", resp.Secrets[0].SecretKey) + + require.Len(t, resp.Imports, 1) + assert.Equal(t, "staging", resp.Imports[0].Environment) + assert.Equal(t, "/", resp.Imports[0].SecretPath) + assert.ElementsMatch(t, []string{"STAGING_DB_URL", "STAGING_API_KEY"}, importSecretKeys(resp.Imports[0])) + }) + + t.Run("exclude imports omits imported secrets", func(t *testing.T) { + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(false), + }) + require.NoError(t, err) + + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "DEV_SECRET", resp.Secrets[0].SecretKey) + assert.Nil(t, resp.Imports, "imports should not be included when IncludeImports=false") + }) +} + +// TestListSecrets_Imports_NestedLevels verifies that a multi-level import chain +// (dev -> staging -> prod) collapses to a single top-level entry whose secrets +// array contains the recursively-resolved secrets from the deeper levels. +func TestListSecrets_Imports_NestedLevels(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-imports-nested").Do() + + // Deepest level. + api.Secrets.Create(proj.ID, "prod", "PROD_KEY", "prod-value").Do() + // Middle level imports the deepest. + api.Secrets.Create(proj.ID, "staging", "STAGING_KEY", "staging-value").Do() + api.Imports.Create(proj.ID, "staging", "/", "prod", "/").Do() + // Top level (requested) imports the middle. + api.Secrets.Create(proj.ID, "dev", "DEV_KEY", "dev-value").Do() + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + + identity := api.Identities.Create("list-imports-nested-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(true), + }) + require.NoError(t, err) + + // Only the directly-configured secret is a direct secret. + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "DEV_KEY", resp.Secrets[0].SecretKey) + + // Only the direct import (staging) is a top-level entry; the nested prod + // import is flattened into it. + require.Len(t, resp.Imports, 1, "only the direct import should be a top-level entry") + assert.Equal(t, "staging", resp.Imports[0].Environment) + assert.Equal(t, "/", resp.Imports[0].SecretPath) + assert.ElementsMatch(t, []string{"STAGING_KEY", "PROD_KEY"}, importSecretKeys(resp.Imports[0]), + "top-level import should contain its own and the recursively-imported secrets") +} + +// TestListSecrets_Imports_Duplicate verifies that a source reachable through more +// than one path (dev imports both staging and prod, and prod also imports +// staging) is resolved only once — the chain resolver dedupes by (env, path). +func TestListSecrets_Imports_Duplicate(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-imports-dup").Do() + + api.Secrets.Create(proj.ID, "staging", "STAGING_KEY", "staging-value").Do() + api.Secrets.Create(proj.ID, "prod", "PROD_KEY", "prod-value").Do() + // prod also imports staging (so staging is reachable twice from dev). + api.Imports.Create(proj.ID, "prod", "/", "staging", "/").Do() + // dev imports both staging (directly) and prod. + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + api.Imports.Create(proj.ID, "dev", "/", "prod", "/").Do() + + identity := api.Identities.Create("list-imports-dup-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(true), + }) + require.NoError(t, err) + + // Two direct imports => two top-level entries (staging, prod). + require.Len(t, resp.Imports, 2) + + // STAGING_KEY must appear exactly once across all import entries: the + // prod->staging duplicate is deduped, not merged into prod's entry too. + stagingCount := 0 + for _, imp := range resp.Imports { + for _, key := range importSecretKeys(imp) { + if key == "STAGING_KEY" { + stagingCount++ + } + } + } + assert.Equal(t, 1, stagingCount, "a doubly-reachable source must resolve only once") +} + +// TestListSecrets_Imports_CircularReference verifies that a cyclic import graph +// terminates instead of looping. A direct 2-node cycle is rejected at creation, +// but the write-time guard only checks the immediate reverse edge, so a 3-node +// cycle (dev -> staging -> prod -> dev) can be formed. Listing dev must still +// return: the resolver's visited-set bounds the walk, and the cycle collapses +// into the single top-level import with every reachable secret merged in. +func TestListSecrets_Imports_CircularReference(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-imports-cycle").Do() + + api.Secrets.Create(proj.ID, "dev", "DEV_KEY", "dev-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_KEY", "staging-value").Do() + api.Secrets.Create(proj.ID, "prod", "PROD_KEY", "prod-value").Do() + + // dev -> staging -> prod -> dev (the prod -> dev edge closes the cycle and is + // accepted because dev does not directly import prod). + api.Imports.Create(proj.ID, "dev", "/", "staging", "/").Do() + api.Imports.Create(proj.ID, "staging", "/", "prod", "/").Do() + api.Imports.Create(proj.ID, "prod", "/", "dev", "/").Do() + + identity := api.Identities.Create("list-imports-cycle-identity") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + // Must return rather than loop on the dev -> staging -> prod -> dev cycle. + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(true), + }) + require.NoError(t, err) + + require.Len(t, resp.Imports, 1, "staging is the only direct import") + assert.Equal(t, "staging", resp.Imports[0].Environment) + // staging (direct) and prod (depth 1) merge into the single top-level entry; + // the cycle back to dev terminates and does not re-surface the origin's own + // secrets, so DEV_KEY is not present in the import. + assert.ElementsMatch(t, []string{"STAGING_KEY", "PROD_KEY"}, importSecretKeys(resp.Imports[0])) +} + +// findImport returns the top-level import entry for (environment, path), or nil. +func findImport(imports []secret.SecretImport, environment, path string) *secret.SecretImport { + for i := range imports { + if imports[i].Environment == environment && imports[i].SecretPath == path { + return &imports[i] + } + } + return nil +} + +// TestListSecrets_Imports_Replication covers a replication import. Replication +// copies the source secrets (asynchronously) into a reserved folder +// (/__reserve_replication_) in the destination environment, but on read the +// import is surfaced under its ORIGINAL source location and its permission is +// evaluated against that original source — not the physical reserved path. So a +// reader scoped only to the destination environment cannot see it. +func TestListSecrets_Imports_Replication(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-imports-replication").Do() + + api.Secrets.Create(proj.ID, "prod", "PROD_SECRET", "prod-secret-value").Do() + // dev replicates prod:/ — secrets are copied into dev's reserved folder async. + api.Imports.Create(proj.ID, "dev", "/", "prod", "/").Replication().Do() + + q := &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: "dev", + SecretPath: new("/"), + ViewSecretValue: new(true), + IncludeImports: new(true), + } + + admin := api.Identities.Create("list-imports-replication-admin") + api.Identities.AddToProject(proj.ID, admin.ID).Role("admin").Do() + adminClient := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(admin.ID, nj.OrgID())). + Build() + + // Replication is async: poll until the reserved folder is populated and the + // import surfaces under its original source (prod:/) with the replicated secret. + var replicated *secret.SecretImport + require.Eventually(t, func() bool { + resp, err := listSecrets(adminClient, q) + if err != nil { + return false + } + imp := findImport(resp.Imports, "prod", "/") + if imp == nil { + return false + } + for _, key := range importSecretKeys(*imp) { + if key == "PROD_SECRET" { + replicated = imp + return true + } + } + return false + }, 90*time.Second, 2*time.Second, "replicated secret should appear under the original source") + + // Surfaced under the original source (prod:/), never the reserved folder path + // (dev:/__reserve_replication_) where the data physically lives. + require.NotNil(t, replicated) + assert.Equal(t, "prod", replicated.Environment) + assert.Equal(t, "/", replicated.SecretPath) + assert.NotContains(t, replicated.SecretPath, "__reserve_replication_") + + // Permission is evaluated against the original source (prod). A reader scoped + // to the destination environment (dev) only — who physically owns the reserved + // folder — must NOT be able to read the replicated value. + devOnly := api.Roles.CreateCustom(proj.ID, "dev-only-reader", "Dev Only Reader", nodejs.Permission{ + Subject: "secrets", + Action: []string{"read"}, + Conditions: map[string]any{"environment": "dev"}, + }) + devReader := api.Identities.Create("list-imports-replication-dev-only") + api.Identities.AddToProject(proj.ID, devReader.ID).Role(devOnly.Slug).Do() + devClient := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(devReader.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(devClient, q) + require.NoError(t, err) + for i := range resp.Imports { + for j := range resp.Imports[i].Secrets { + s := resp.Imports[i].Secrets[j] + if s.SecretKey == "PROD_SECRET" { + assert.NotEqual(t, "prod-secret-value", s.SecretValue, + "dev-only reader must not read a value replicated from prod (permission follows the source)") + } + } + } +} diff --git a/backend-go/tests/secrets/secrets/list_secrets_permission_test.go b/backend-go/tests/secrets/secrets/list_secrets_permission_test.go new file mode 100644 index 00000000000..8a1b095b6e0 --- /dev/null +++ b/backend-go/tests/secrets/secrets/list_secrets_permission_test.go @@ -0,0 +1,495 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +// TestListSecrets_Permission_IdentityRole covers base project roles assigned to +// a machine identity, plus the not-a-member case. +func TestListSecrets_Permission_IdentityRole(t *testing.T) { + tests := []struct { + name string + role string + addToProject bool + wantErr string + wantSecret bool + }{ + {name: "admin can read", role: "admin", addToProject: true, wantSecret: true}, + {name: "member can read", role: "member", addToProject: true, wantSecret: true}, + {name: "viewer can read", role: "viewer", addToProject: true, wantSecret: true}, + {name: "no-access sees nothing", role: "no-access", addToProject: true, wantSecret: false}, + {name: "non-member is forbidden", addToProject: false, wantErr: "not a member"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-identity-role").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "ROLE_SECRET", "role-value").Do() + + identity := api.Identities.Create("list-perm-identity-role-id") + if tc.addToProject { + api.Identities.AddToProject(proj.ID, identity.ID).Role(tc.role).Do() + } + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + if tc.wantSecret { + require.Len(t, resp.Secrets, 1) + assert.False(t, resp.Secrets[0].SecretValueHidden) + assert.Equal(t, "role-value", resp.Secrets[0].SecretValue) + } else { + assert.Empty(t, resp.Secrets) + } + }) + } +} + +// TestListSecrets_Permission_UserRole covers base project roles assigned to a +// user via direct membership. +func TestListSecrets_Permission_UserRole(t *testing.T) { + tests := []struct { + name string + role string + }{ + {name: "admin can read", role: "admin"}, + {name: "viewer can read", role: "viewer"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-user-role").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "USER_SECRET", "user-value").Do() + + user := api.Users.InviteAndCreate("list-perm-user-" + tc.role + "@test.local") + api.Users.AddToProject(proj.ID, user.Email).Role(tc.role).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.UserIdentity(user.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.False(t, resp.Secrets[0].SecretValueHidden) + assert.Equal(t, "user-value", resp.Secrets[0].SecretValue) + }) + } +} + +func TestListSecrets_Permission_CustomRoleEnvironmentScoped(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-env-scoped").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_SECRET", "dev-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_SECRET", "staging-value").Do() + + customRole := api.Roles.CreateCustom(proj.ID, "dev-only-reader", "Dev Only Reader", nodejs.Permission{ + Subject: "secrets", Action: []string{"read"}, Conditions: map[string]any{"environment": "dev"}, + }) + + identity := api.Identities.Create("list-perm-env-scoped-id") + api.Identities.AddToProject(proj.ID, identity.ID).Role(customRole.Slug).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + dev, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "dev", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, dev.Secrets, 1) + assert.Equal(t, "DEV_SECRET", dev.Secrets[0].SecretKey) + + staging, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "staging", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + assert.Empty(t, staging.Secrets, "should not see staging secrets with dev-only role") +} + +func TestListSecrets_Permission_CustomRolePathScoped(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-path-scoped").Do() + + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "app") + api.Folders.Create(proj.ID, proj.EnvSlug, "/app", "config") + api.Folders.Create(proj.ID, proj.EnvSlug, "/", "other") + + api.Secrets.Create(proj.ID, proj.EnvSlug, "APP_SECRET", "app-value").Path("/app").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "CONFIG_SECRET", "config-value").Path("/app/config").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "OTHER_SECRET", "other-value").Path("/other").Do() + + customRole := api.Roles.CreateCustom(proj.ID, "app-reader", "App Path Reader", nodejs.Permission{ + Subject: "secrets", Action: []string{"read"}, Conditions: map[string]any{"secretPath": map[string]any{"$glob": "/app/**"}}, + }) + + identity := api.Identities.Create("list-perm-path-scoped-id") + api.Identities.AddToProject(proj.ID, identity.ID).Role(customRole.Slug).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + app, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: proj.EnvSlug, SecretPath: new("/app"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, app.Secrets, 1) + assert.Equal(t, "APP_SECRET", app.Secrets[0].SecretKey) + + config, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: proj.EnvSlug, SecretPath: new("/app/config"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, config.Secrets, 1) + assert.Equal(t, "CONFIG_SECRET", config.Secrets[0].SecretKey) + + other, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: proj.EnvSlug, SecretPath: new("/other"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + assert.Empty(t, other.Secrets, "should not see /other secrets with /app/** role") +} + +// TestListSecrets_Permission_GroupRole covers a user inheriting a base project +// role through group membership. +func TestListSecrets_Permission_GroupRole(t *testing.T) { + tests := []struct { + name string + role string + }{ + {name: "admin inherited via group", role: "admin"}, + {name: "viewer inherited via group", role: "viewer"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-group-role").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "GROUP_SECRET", "group-value").Do() + + group := api.Groups.Create("list-perm-group-" + tc.role) + user := api.Users.InviteAndCreate("list-perm-group-" + tc.role + "@test.local") + api.Groups.AddUser(group.ID, user.Email) + api.Groups.AddToProject(proj.ID, group.ID, tc.role) + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.UserIdentity(user.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.False(t, resp.Secrets[0].SecretValueHidden) + assert.Equal(t, "group-value", resp.Secrets[0].SecretValue) + }) + } +} + +func TestListSecrets_Permission_GroupCustomRole(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-group-custom").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_SECRET", "dev-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_SECRET", "staging-value").Do() + + customRole := api.Roles.CreateCustom(proj.ID, "group-dev-reader", "Group Dev Reader", nodejs.Permission{ + Subject: "secrets", Action: []string{"read"}, Conditions: map[string]any{"environment": "dev"}, + }) + + group := api.Groups.Create("list-perm-custom-role-group") + user := api.Users.InviteAndCreate("list-perm-group-custom@test.local") + api.Groups.AddUser(group.ID, user.Email) + api.Groups.AddToProject(proj.ID, group.ID, customRole.Slug) + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.UserIdentity(user.ID, nj.OrgID())). + Build() + + dev, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "dev", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, dev.Secrets, 1) + assert.Equal(t, "DEV_SECRET", dev.Secrets[0].SecretKey) + + staging, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "staging", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + assert.Empty(t, staging.Secrets) +} + +func TestListSecrets_Permission_AdditionalPrivilegeExtendsRole(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-addl-priv").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_SECRET", "dev-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_SECRET", "staging-value").Do() + + identity := api.Identities.Create("list-perm-addl-priv-id") + api.Identities.AddToProject(proj.ID, identity.ID).Role("no-access").Do() + api.Identities.AdditionalPrivilege(identity.ID, proj.ID, nodejs.Permission{ + Subject: "secrets", Action: "read", Conditions: map[string]any{"environment": "dev"}, + }).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + dev, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "dev", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, dev.Secrets, 1) + assert.Equal(t, "DEV_SECRET", dev.Secrets[0].SecretKey) + + staging, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "staging", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + assert.Empty(t, staging.Secrets) +} + +func TestListSecrets_Permission_MultipleAdditionalPrivilegesMerge(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-multi-addl-priv").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_SECRET", "dev-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_SECRET", "staging-value").Do() + + identity := api.Identities.Create("list-perm-multi-addl-priv-id") + api.Identities.AddToProject(proj.ID, identity.ID).Role("no-access").Do() + api.Identities.AdditionalPrivilege(identity.ID, proj.ID, nodejs.Permission{ + Subject: "secrets", Action: "read", Conditions: map[string]any{"environment": "dev"}, + }).Do() + api.Identities.AdditionalPrivilege(identity.ID, proj.ID, nodejs.Permission{ + Subject: "secrets", Action: "read", Conditions: map[string]any{"environment": "staging"}, + }).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + dev, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "dev", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, dev.Secrets, 1) + + staging, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, Environment: "staging", SecretPath: new("/"), ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, staging.Secrets, 1) +} + +// TestListSecrets_Permission_TemporaryRole covers temporary project role grants: +// an active grant allows access, an expired one falls back to the permanent role. +func TestListSecrets_Permission_TemporaryRole(t *testing.T) { + tests := []struct { + name string + startOffset time.Duration + baseRole string + wantSecret bool + }{ + {name: "active grants access", startOffset: 0, baseRole: "no-access", wantSecret: true}, + {name: "expired denies access", startOffset: -2 * time.Hour, baseRole: "no-access", wantSecret: false}, + {name: "expired falls back to permanent viewer", startOffset: -2 * time.Hour, baseRole: "viewer", wantSecret: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-temp-role").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "TEMP_SECRET", "temp-value").Do() + + identity := api.Identities.Create("list-perm-temp-role-id") + api.Identities.AddToProject(proj.ID, identity.ID).Roles( + nodejs.RoleAssignment{Role: tc.baseRole, IsTemporary: false}, + nodejs.RoleAssignment{ + Role: "admin", + IsTemporary: true, + TemporaryMode: "relative", + TemporaryRange: "1h", + TemporaryAccessStartTime: time.Now().Add(tc.startOffset).UTC().Format(time.RFC3339), + }, + ).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + if tc.wantSecret { + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "temp-value", resp.Secrets[0].SecretValue) + } else { + assert.Empty(t, resp.Secrets, "expired temporary role should not grant access") + } + }) + } +} + +// TestListSecrets_Permission_TemporaryAdditionalPrivilege covers temporary +// additional privileges: active grants access, expired does not. +func TestListSecrets_Permission_TemporaryAdditionalPrivilege(t *testing.T) { + tests := []struct { + name string + startOffset time.Duration + wantSecret bool + }{ + {name: "active grants access", startOffset: 0, wantSecret: true}, + {name: "expired denies access", startOffset: -2 * time.Hour, wantSecret: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-temp-addl").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "TEMP_ADDL_SECRET", "temp-addl-value").Do() + + identity := api.Identities.Create("list-perm-temp-addl-id") + api.Identities.AddToProject(proj.ID, identity.ID).Role("no-access").Do() + api.Identities.AdditionalPrivilege(identity.ID, proj.ID, nodejs.Permission{ + Subject: "secrets", Action: "read", + }).Temporary("1h", time.Now().Add(tc.startOffset).UTC().Format(time.RFC3339)).Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + if tc.wantSecret { + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "temp-addl-value", resp.Secrets[0].SecretValue) + } else { + assert.Empty(t, resp.Secrets, "expired temporary additional privilege should not grant access") + } + }) + } +} + +// TestListSecrets_Permission_ViewSecretValue verifies that viewSecretValue masks +// or reveals the value. +func TestListSecrets_Permission_ViewSecretValue(t *testing.T) { + tests := []struct { + name string + view bool + wantHidden bool + wantValue string + }{ + {name: "false masks the value", view: false, wantHidden: true, wantValue: ""}, + {name: "true reveals the value", view: true, wantHidden: false, wantValue: "real-value"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("list-perm-view-value").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "VALUE_SECRET", "real-value").Do() + + identity := api.Identities.Create("list-perm-view-value-id") + api.Identities.AddToProject(proj.ID, identity.ID).Role("admin").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.MachineIdentity(identity.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: proj.EnvSlug, + SecretPath: new("/"), + ViewSecretValue: new(tc.view), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, tc.wantHidden, resp.Secrets[0].SecretValueHidden) + assert.Equal(t, tc.wantValue, resp.Secrets[0].SecretValue) + }) + } +} diff --git a/backend-go/tests/secrets/secrets/main_test.go b/backend-go/tests/secrets/secrets/main_test.go new file mode 100644 index 00000000000..6b609199d49 --- /dev/null +++ b/backend-go/tests/secrets/secrets/main_test.go @@ -0,0 +1,163 @@ +//go:build integration + +package secrets_test + +import ( + "fmt" + "net/http" + "net/url" + "os" + "testing" + + "go.uber.org/goleak" + + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/keystore" + "github.com/infisical/api/internal/queue" + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/internal/server/api/shared" + "github.com/infisical/api/internal/services/auditlog" + "github.com/infisical/api/internal/services/kms" + "github.com/infisical/api/internal/services/permission" + "github.com/infisical/api/internal/services/project" + secretSvc "github.com/infisical/api/internal/services/secrets/secret" + "github.com/infisical/api/internal/services/secrets/secretcache" + "github.com/infisical/api/internal/services/secrets/secretfolder" + "github.com/infisical/api/internal/services/secrets/secretimport" + "github.com/infisical/api/tests/infra" +) + +var stack *infra.Stack + +func TestMain(m *testing.M) { + stack = infra.New(). + WithPostgres(). + WithRedis(). + WithNodeJSApi(). + WithEEFeatures("rbac", "groups", "secretApproval"). + MustStart() + + code := m.Run() + + stack.Stop() + + // Check for goroutine leaks only after a clean run. Setup must precede + // m.Run(), so goleak.VerifyTestMain can't be used here. + if code == 0 { + if err := goleak.Find( + goleak.IgnoreTopFunction("github.com/redis/go-redis/v9/internal/pool.(*ConnPool).reaper"), + ); err != nil { + fmt.Fprintf(os.Stderr, "goleak: %v\n", err) + os.Exit(1) + } + } + + os.Exit(code) +} + +// newSecretsHandler creates a secrets handler wired with all dependencies. +// Build one per test (or once for cache tests that exercise in-process handler +// state) and reuse it across requests. +func newSecretsHandler(t *testing.T) *secret.Handler { + t.Helper() + + ctx := t.Context() + + permSvc := permission.NewService(ctx, infra.NopLogger(), &permission.Deps{DB: stack.DB()}) + + redisClient := stack.Redis().Client() + t.Cleanup(func() { redisClient.Close() }) + + kmsSvc, err := kms.NewService(ctx, infra.NopLogger(), &kms.Deps{ + DB: stack.DB(), + HSM: nil, + ExternalKms: nil, + Config: stack.Config(), + }) + require.NoError(t, err) + + err = kmsSvc.Start(ctx, false) + require.NoError(t, err) + + projectSvc := project.NewService(ctx, infra.NopLogger(), &project.Deps{DB: stack.DB()}) + + queueSvc := queue.NewService(ctx, infra.NopLogger(), redisClient) + + auditLogSvc := auditlog.NewService(ctx, infra.NopLogger(), &auditlog.Deps{Queue: queueSvc, Config: stack.Config()}) + + secretFolderSvc := secretfolder.NewService(ctx, infra.NopLogger(), &secretfolder.Deps{DB: stack.DB()}) + secretImportSvc := secretimport.NewService(ctx, infra.NopLogger(), &secretimport.Deps{DB: stack.DB()}) + + secretsSvc := secretSvc.NewService(ctx, infra.NopLogger(), &secretSvc.Deps{ + DB: stack.DB(), + SecretFolderService: secretFolderSvc, + SecretImportService: secretImportSvc, + KMSService: kmsSvc, + }) + + ks := keystore.NewKeyStore(redisClient, stack.DB()) + secretCacheSvc := secretcache.NewService(ctx, infra.NopLogger(), &secretcache.Deps{KeyStore: ks}) + + return secret.NewHandler(&secret.Deps{ + Logger: infra.NopLogger(), + Permission: permSvc, + Project: projectSvc, + AuditLog: auditLogSvc, + Secrets: secretsSvc, + KMS: kmsSvc, + SecretCache: secretCacheSvc, + }) +} + +// newSecretsRouter creates an HTTP router for the secrets endpoints. +func newSecretsRouter(t *testing.T) http.Handler { + t.Helper() + return secret.NewRouter( + newSecretsHandler(t), + secret.WithErrorHandler(shared.NewErrorHandler(infra.NopLogger())), + ) +} + +// ============================================================================= +// Endpoint helpers +// +// Requests are built from the generated query structs and encoded via Params, +// so a new spec param appears as a struct field with no helper edit. Negative +// tests that need invalid/omitted input should call the client directly with +// raw Param/ExpectStatus instead. +// ============================================================================= + +func listSecrets(client *infra.HTTPClient, q *secret.ListSecretsV4Query) (secret.ListSecretsV4Response, error) { + var resp secret.ListSecretsV4Response + err := client.Get("/api/v4/secrets").Params(q).Into(&resp) + return resp, err +} + +func getSecret(client *infra.HTTPClient, name string, q *secret.GetSecretByNameV4Query) (secret.GetSecretByNameV4Response, error) { + var resp secret.GetSecretByNameV4Response + err := client.Get("/api/v4/secrets/" + url.PathEscape(name)).Params(q).Into(&resp) + return resp, err +} + +func listSecretsV3(client *infra.HTTPClient, q *secret.ListSecretsRawV3Query) (secret.ListSecretsV4Response, error) { + var resp secret.ListSecretsV4Response + err := client.Get("/api/v3/secrets/raw").Params(q).Into(&resp) + return resp, err +} + +func getSecretV3(client *infra.HTTPClient, name string, q *secret.GetSecretByNameRawV3Query) (secret.GetSecretByNameV4Response, error) { + var resp secret.GetSecretByNameV4Response + err := client.Get("/api/v3/secrets/raw/" + url.PathEscape(name)).Params(q).Into(&resp) + return resp, err +} + +// findSecret returns the secret with the given key from a list response, or nil. +func findSecret(secrets []secret.SecretRaw, key string) *secret.SecretRaw { + for i := range secrets { + if secrets[i].SecretKey == key { + return &secrets[i] + } + } + return nil +} diff --git a/backend-go/tests/secrets/secrets/service_token_test.go b/backend-go/tests/secrets/secrets/service_token_test.go new file mode 100644 index 00000000000..734382e617d --- /dev/null +++ b/backend-go/tests/secrets/secrets/service_token_test.go @@ -0,0 +1,152 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" + "github.com/infisical/api/tests/infra/nodejs" +) + +// Service-token scope enforcement is handler/permission logic and is reachable +// here. Token expiry and bearer validation live in the auth middleware (which +// these tests bypass by injecting the identity) and are covered by auth tests. + +func TestServiceToken_ListSecrets_Scope(t *testing.T) { + tests := []struct { + name string + tokenScope nodejs.ServiceTokenScope + listEnv string + listPath string + wantKeys []string + description string + }{ + { + name: "within env and path scope", + tokenScope: nodejs.ServiceTokenScope{Environment: "dev", SecretPath: "/"}, + listEnv: "dev", + listPath: "/", + wantKeys: []string{"DEV_ROOT"}, + }, + { + name: "outside env scope returns empty", + tokenScope: nodejs.ServiceTokenScope{Environment: "dev", SecretPath: "/"}, + listEnv: "staging", + listPath: "/", + wantKeys: nil, + }, + { + name: "outside path scope returns empty", + tokenScope: nodejs.ServiceTokenScope{Environment: "dev", SecretPath: "/app"}, + listEnv: "dev", + listPath: "/", + wantKeys: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("svc-token-list").Do() + api.Folders.Create(proj.ID, "dev", "/", "app") + api.Secrets.Create(proj.ID, "dev", "DEV_ROOT", "dev-root-value").Do() + api.Secrets.Create(proj.ID, "dev", "APP_SECRET", "app-value").Path("/app").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_ROOT", "staging-value").Do() + + token := api.ServiceTokens.Create(proj.ID). + Scopes(tc.tokenScope). + Permissions("read"). + Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.ServiceTokenIdentity(token.ID, nj.OrgID())). + Build() + + resp, err := listSecrets(client, &secret.ListSecretsV4Query{ + ProjectID: proj.ID, + Environment: tc.listEnv, + SecretPath: new(tc.listPath), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + + keys := make([]string, len(resp.Secrets)) + for i, s := range resp.Secrets { + keys[i] = s.SecretKey + } + assert.ElementsMatch(t, tc.wantKeys, keys) + }) + } +} + +func TestServiceToken_GetSecretByName_Scope(t *testing.T) { + tests := []struct { + name string + getEnv string + getPath string + secretName string + wantValue string + wantErr bool + }{ + { + name: "within scope returns secret", + getEnv: "dev", + getPath: "/", + secretName: "DEV_ROOT", + wantValue: "dev-root-value", + }, + { + name: "outside env scope is denied", + getEnv: "staging", + getPath: "/", + secretName: "STAGING_ROOT", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("svc-token-get").Do() + api.Secrets.Create(proj.ID, "dev", "DEV_ROOT", "dev-root-value").Do() + api.Secrets.Create(proj.ID, "staging", "STAGING_ROOT", "staging-value").Do() + + // Token scoped to dev:/ only. + token := api.ServiceTokens.Create(proj.ID). + Scopes(nodejs.ServiceTokenScope{Environment: "dev", SecretPath: "/"}). + Permissions("read"). + Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.ServiceTokenIdentity(token.ID, nj.OrgID())). + Build() + + resp, err := getSecret(client, tc.secretName, &secret.GetSecretByNameV4Query{ + ProjectID: proj.ID, + Environment: tc.getEnv, + SecretPath: new(tc.getPath), + ViewSecretValue: new(true), + }) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.secretName, resp.Secret.SecretKey) + assert.Equal(t, tc.wantValue, resp.Secret.SecretValue) + }) + } +} diff --git a/backend-go/tests/secrets/secrets/v3_test.go b/backend-go/tests/secrets/secrets/v3_test.go new file mode 100644 index 00000000000..b04b9cac376 --- /dev/null +++ b/backend-go/tests/secrets/secrets/v3_test.go @@ -0,0 +1,93 @@ +//go:build integration + +package secrets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/infisical/api/internal/server/api/secrets/secret" + "github.com/infisical/api/tests/infra" +) + +// TestGetSecretByNameRawV3 covers the deprecated V3 raw endpoint, which accepts +// either workspaceId or workspaceSlug instead of projectId. +func TestGetSecretByNameRawV3(t *testing.T) { + tests := []struct { + name string + useSlug bool // otherwise workspaceId + }{ + {name: "with workspaceSlug", useSlug: true}, + {name: "with workspaceId", useSlug: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("v3-get").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "V3_SECRET", "v3-value").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.UserIdentity(nj.UserID(), nj.OrgID())). + Build() + + q := secret.GetSecretByNameRawV3Query{ + Environment: new(proj.EnvSlug), + SecretPath: new("/"), + ViewSecretValue: new(true), + } + if tc.useSlug { + q.WorkspaceSlug = new(proj.Slug) + } else { + q.WorkspaceID = new(proj.ID) + } + + resp, err := getSecretV3(client, "V3_SECRET", &q) + + require.NoError(t, err) + assert.Equal(t, "V3_SECRET", resp.Secret.SecretKey) + assert.Equal(t, "v3-value", resp.Secret.SecretValue) + }) + } +} + +// TestListSecretsRawV3 covers the deprecated V3 raw list endpoint. +func TestListSecretsRawV3(t *testing.T) { + t.Parallel() + nj := stack.NodeJS() + api := nj.For(t) + + proj := api.Projects.Create("v3-list").Do() + api.Secrets.Create(proj.ID, proj.EnvSlug, "V3_SECRET", "v3-value").Do() + + client := infra.NewClientBuilder(t, newSecretsRouter(t)). + Identity(infra.UserIdentity(nj.UserID(), nj.OrgID())). + Build() + + t.Run("with workspace slug", func(t *testing.T) { + resp, err := listSecretsV3(client, &secret.ListSecretsRawV3Query{ + WorkspaceSlug: new(proj.Slug), + Environment: new(proj.EnvSlug), + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.NoError(t, err) + require.Len(t, resp.Secrets, 1) + assert.Equal(t, "V3_SECRET", resp.Secrets[0].SecretKey) + }) + + t.Run("requires workspace id or slug", func(t *testing.T) { + _, err := listSecretsV3(client, &secret.ListSecretsRawV3Query{ + Environment: new("dev"), + SecretPath: new("/"), + ViewSecretValue: new(true), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "workspaceId or workspaceSlug") + }) +} diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b4d97a9b5fd..2e3fb53a7e6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1926,6 +1926,7 @@ export const registerRoutes = async ( keyStore, licenseService, folderDAL, + secretDAL: secretV2BridgeDAL, accessApprovalPolicyEnvironmentDAL, secretApprovalPolicyEnvironmentDAL: sapEnvironmentDAL }); diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index 2ba756c2762..a12a49f9117 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -12,6 +12,7 @@ import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectEnvDALFactory } from "./project-env-dal"; import { SOFT_DELETE_GRACE_MS } from "./project-env-queue"; import { TCreateEnvDTO, TDeleteEnvDTO, TGetEnvDTO, TRestoreEnvDTO, TUpdateEnvDTO } from "./project-env-types"; @@ -19,6 +20,7 @@ import { TCreateEnvDTO, TDeleteEnvDTO, TGetEnvDTO, TRestoreEnvDTO, TUpdateEnvDTO type TProjectEnvServiceFactoryDep = { projectEnvDAL: TProjectEnvDALFactory; folderDAL: Pick; + secretDAL: Pick; permissionService: Pick; licenseService: Pick; keyStore: Pick; @@ -35,7 +37,8 @@ export const projectEnvServiceFactory = ({ keyStore, folderDAL, accessApprovalPolicyEnvironmentDAL, - secretApprovalPolicyEnvironmentDAL + secretApprovalPolicyEnvironmentDAL, + secretDAL }: TProjectEnvServiceFactoryDep) => { const createEnvironment = async ({ projectId, @@ -221,6 +224,7 @@ export const projectEnvServiceFactory = ({ "true" ); + await secretDAL.invalidateSecretCacheByProjectId(projectId); return { environment: env, old: oldEnv }; } finally { await lock?.release(); @@ -330,6 +334,8 @@ export const projectEnvServiceFactory = ({ "true" ); + await secretDAL.invalidateSecretCacheByProjectId(projectId); + return env; } finally { await lock?.release(); @@ -408,6 +414,7 @@ export const projectEnvServiceFactory = ({ return doc; }); + await secretDAL.invalidateSecretCacheByProjectId(projectId); return env; } finally { await keyStore.deleteItem(operationMarkerKey);