diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d3201a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: [8.1, 8.2, 8.3] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite + coverage: xdebug + + - name: Install dependencies + run: composer install + + - name: Run PHPStan + run: composer phpstan + + - name: Validate composer.json + run: composer validate + + - name: Check for security vulnerabilities + run: composer audit --format=json --no-interaction diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml new file mode 100644 index 0000000..37728a1 --- /dev/null +++ b/.github/workflows/packagist.yml @@ -0,0 +1,44 @@ +name: Deploy to Packagist + +on: + push: + tags: + - "v*.*.*" + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite + + - name: Install dependencies + run: composer install --no-dev --optimize-autoloader + + - name: Validate composer.json + run: composer validate + + - name: Check for security vulnerabilities + run: composer audit --format=json --no-interaction + + publish-to-packagist: + name: publish + runs-on: ubuntu-latest + needs: deploy + + steps: + - uses: actions/checkout@v4 + + - name: Publish to Packagist + run: |- + curl --fail-with-body -X POST -H 'Content-Type: application/json' "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_API_KEY}" -d '{"repository":"github.com/Infisical/php-sdk"}' + env: + PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} + PACKAGIST_API_KEY: ${{ secrets.PACKAGIST_API_KEY }} diff --git a/.gitignore b/.gitignore index d69f71f..b05cff3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,37 @@ -# the composer package lock file and install directory -# Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file -# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file -# /composer.lock -/fuel/vendor - -# the fuelphp document -/docs/ - -# you may install these packages with `oil package`. -# http://fuelphp.com/docs/packages/oil/package.html -# /fuel/packages/auth/ -# /fuel/packages/email/ -# /fuel/packages/oil/ -# /fuel/packages/orm/ -# /fuel/packages/parser/ - -# dynamically generated files -/fuel/app/logs/*/*/* -/fuel/app/cache/*/* -/fuel/app/config/crypt.php +# Composer +/vendor/ +composer.lock + +# PHPUnit +.phpunit.cache/ +.phpunit.result.cache + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment files +.env +.env.local +.env.*.local + +# Build artifacts +/build/ +/dist/ + +# Coverage reports +/coverage/ +/html-coverage/ + +# Temporary files +*.tmp +*.temp diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..00d681a --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,38 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']) + ->exclude('vendor'); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => true, + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => [ + 'break', + 'continue', + 'declare', + 'return', + 'throw', + 'try', + ], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + ]) + ->setFinder($finder); diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ea9963 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +

+ infisical +

+

+

Infisical PHP SDK

+

+ | Slack | + Infisical | + Documentation | +

+ +

+ + Infisical PHP SDK is released under the MIT license. + + + Slack community channel + + + Infisical Twitter + +

+ +## Introduction + +**[Infisical](https://infisical.com)** is the open source secret management platform that teams use to centralize their secrets like API keys, database credentials, and configurations. + +## Documentation +You can find the documentation for the PHP SDK on our [SDK documentation page](https://infisical.com/docs/sdks/languages/php) + +## Security + +Please do not file GitHub issues or post on our public forum for security vulnerabilities, as they are public! + +Infisical takes security issues very seriously. If you have any concerns about Infisical or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@infisical.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible. + +Note that this security address should be used only for undisclosed vulnerabilities. Please report any security problems to us before disclosing it publicly. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..159525c --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "name": "infisical/php-sdk", + "description": "Official PHP SDK for Infisical", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Daniel Hougaard", + "email": "daniel@infisical.com" + } + ], + "require": { + "php": ">=8.1", + "guzzlehttp/guzzle": "^7.0", + "cuyz/valinor": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "phpstan/phpstan": "^1.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "autoload": { + "psr-4": { + "Infisical\\SDK\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Infisical\\SDK\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "phpstan": "phpstan analyse src", + "cs": "phpcs src", + "cs-fix": "phpcbf src" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..97e114f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 8 + paths: + - src + excludePaths: + - vendor diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..49e2da9 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,27 @@ + + + + + tests + + + + + src + + + vendor + tests + + + + + + diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php new file mode 100644 index 0000000..30ed0df --- /dev/null +++ b/src/Http/HttpClient.php @@ -0,0 +1,124 @@ + $headers + */ + public function __construct(string $baseUrl, array $headers = []) + { + $this->baseUrl = rtrim($baseUrl, '/'); + $this->client = new Client( + [ + 'base_uri' => $this->baseUrl, + 'timeout' => 30, + 'headers' => array_merge( + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], $headers + ), + ] + ); + } + + /** + * Send a GET request + * + * @param string $endpoint + * @param array $query + * @param array $headers + * @return ResponseInterface + * @throws GuzzleException + */ + public function get(string $endpoint, array $query = [], array $headers = []): ResponseInterface + { + return $this->client->get( + $endpoint, [ + 'query' => $query, + 'headers' => $headers, + ] + ); + } + + /** + * Send a POST request + * + * @param string $endpoint + * @param array $data + * @param array $headers + * @return ResponseInterface + * @throws GuzzleException + */ + public function post(string $endpoint, array $data = [], array $headers = []): ResponseInterface + { + return $this->client->post( + $endpoint, [ + 'json' => $data, + 'headers' => $headers, + ] + ); + } + + /** + * Send a PATCH request + * + * @param string $endpoint + * @param array $data + * @param array $headers + * @return ResponseInterface + * @throws GuzzleException + */ + public function patch(string $endpoint, array $data = [], array $headers = []): ResponseInterface + { + return $this->client->patch( + $endpoint, [ + 'json' => $data, + 'headers' => $headers, + ] + ); + } + + /** + * Send a DELETE request + * + * @param string $endpoint + * @param array $data + * @param array $headers + * @return ResponseInterface + * @throws GuzzleException + */ + public function delete(string $endpoint, array $data = [], array $headers = []): ResponseInterface + { + return $this->client->delete( + $endpoint, [ + 'json' => $data, + 'headers' => $headers, + ] + ); + } + + /** + * Get the base URL + * + * @return string + */ + public function getBaseUrl(): string + { + return $this->baseUrl; + } +} diff --git a/src/InfisicalSDK.php b/src/InfisicalSDK.php new file mode 100644 index 0000000..1728286 --- /dev/null +++ b/src/InfisicalSDK.php @@ -0,0 +1,69 @@ +host = $host; + $this->httpClient = new HttpClient($host); + + $this->secretsService = new SecretsService($this->httpClient); + $this->authService = new AuthService($this->httpClient, fn(string $token) => $this->onAuthenticate($token)); + } + + /** + * Get the secrets service + * + * @return SecretsService + */ + public function secrets(): SecretsService + { + return $this->secretsService; + } + + /** + * Get the auth service + * + * @return AuthService + */ + public function auth(): AuthService + { + return $this->authService; + } + + /** + * Handle authentication callback + */ + private function onAuthenticate(string $accessToken): void + { + $this->httpClient = new HttpClient( + $this->host, [ + 'Authorization' => 'Bearer ' . $accessToken, + ] + ); + + $this->secretsService = new SecretsService($this->httpClient); + $this->authService = new AuthService($this->httpClient, fn(string $token) => $this->onAuthenticate($token)); + } +} diff --git a/src/Models/CreateSecretParameters.php b/src/Models/CreateSecretParameters.php new file mode 100644 index 0000000..f995a43 --- /dev/null +++ b/src/Models/CreateSecretParameters.php @@ -0,0 +1,58 @@ + + */ + public function toArray(): array + { + $params = []; + + if ($this->environment !== null) { + $params['environment'] = $this->environment; + } + + if ($this->projectId !== null) { + $params['workspaceId'] = $this->projectId; + } + + if ($this->secretPath !== null) { + $params['secretPath'] = $this->secretPath; + } + + if ($this->secretKey !== null) { + $params['secretKey'] = $this->secretKey; + } + + if ($this->secretValue !== null) { + $params['secretValue'] = $this->secretValue; + } + + if ($this->secretComment !== null) { + $params['secretComment'] = $this->secretComment; + } + + return $params; + } + +} diff --git a/src/Models/DeleteSecretParameters.php b/src/Models/DeleteSecretParameters.php new file mode 100644 index 0000000..dd8046f --- /dev/null +++ b/src/Models/DeleteSecretParameters.php @@ -0,0 +1,48 @@ + + */ + public function toArray(): array + { + $params = []; + + if ($this->secretKey !== null) { + $params['secretKey'] = $this->secretKey; + } + + if ($this->environment !== null) { + $params['environment'] = $this->environment; + } + + if ($this->projectId !== null) { + $params['workspaceId'] = $this->projectId; + } + + if ($this->secretPath !== null) { + $params['secretPath'] = $this->secretPath; + } + + return $params; + } + +} diff --git a/src/Models/GetSecretParameters.php b/src/Models/GetSecretParameters.php new file mode 100644 index 0000000..b330b09 --- /dev/null +++ b/src/Models/GetSecretParameters.php @@ -0,0 +1,66 @@ + + */ + public function toArray(): array + { + $params = []; + + if ($this->secretKey !== null) { + $params['secretKey'] = $this->secretKey; + } + + if ($this->environment !== null) { + $params['environment'] = $this->environment; + } + + if ($this->projectId !== null) { + $params['workspaceId'] = $this->projectId; + } + + if ($this->secretPath !== null) { + $params['secretPath'] = $this->secretPath; + } + + if ($this->version !== null) { + $params['version'] = $this->version; + } + + if ($this->type !== null) { + $params['type'] = $this->type; + } + + if ($this->expandSecretReferences !== null) { + $params['expandSecretReferences'] = $this->boolToString($this->expandSecretReferences); + } + + return $params; + } + private function boolToString(bool $value): string + { + return $value ? 'true' : 'false'; + } +} diff --git a/src/Models/ListSecretsParameters.php b/src/Models/ListSecretsParameters.php new file mode 100644 index 0000000..ee6ed88 --- /dev/null +++ b/src/Models/ListSecretsParameters.php @@ -0,0 +1,77 @@ +|null + */ + public readonly ?array $tagSlugs = null, + public readonly ?bool $attachToProcessEnv = null, + public readonly ?bool $skipUniqueValidation = null, + ) { + } + + /** + * Convert to array for HTTP request + * + * @return array + */ + public function toArray(): array + { + $params = []; + + if ($this->environment !== null) { + $params['environment'] = $this->environment; + } + + if ($this->secretPath !== null) { + $params['secretPath'] = $this->secretPath; + } + + if ($this->projectId !== null) { + $params['workspaceId'] = $this->projectId; + } + + if ($this->expandSecretReferences !== null) { + $params['expandSecretReferences'] = $this->boolToString($this->expandSecretReferences); + } + + if ($this->recursive !== null) { + $params['recursive'] = $this->boolToString($this->recursive); + } + + if ($this->tagSlugs !== null) { + $params['tagSlugs'] = implode(',', $this->tagSlugs); + } + + if ($this->attachToProcessEnv !== null) { + $params['attachToProcessEnv'] = $this->attachToProcessEnv; + } + + if ($this->skipUniqueValidation !== null) { + $params['skipUniqueValidation'] = $this->skipUniqueValidation; + } + + // We forcefully include imports as we're trying to move to a structure where users won't have to worry about imports vs. secrets. + $params["include_imports"] = "true"; + + return $params; + } + private function boolToString(bool $value): string + { + return $value ? 'true' : 'false'; + } +} diff --git a/src/Models/MachineIdentityCredential.php b/src/Models/MachineIdentityCredential.php new file mode 100644 index 0000000..f49fd73 --- /dev/null +++ b/src/Models/MachineIdentityCredential.php @@ -0,0 +1,50 @@ + $data + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + $data['accessToken'] ?? '', + $data['expiresIn'] ?? 0, + $data['accessTokenMaxTTL'] ?? 0, + $data['tokenType'] ?? '' + ); + } + + /** + * Convert to array + * + * @return array + */ + public function toArray(): array + { + return [ + 'accessToken' => $this->accessToken, + 'expiresIn' => $this->expiresIn, + 'accessTokenMaxTTL' => $this->accessTokenMaxTTL, + 'tokenType' => $this->tokenType, + ]; + } +} diff --git a/src/Models/Secret.php b/src/Models/Secret.php new file mode 100644 index 0000000..6beef73 --- /dev/null +++ b/src/Models/Secret.php @@ -0,0 +1,57 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['secretKey'] ?? '', + $data['secretValue'] ?? '', + $data['id'] ?? '', + $data['workspaceId'] ?? '', + $data['environment'] ?? '', + $data['version'] ?? 0, + $data['type'] ?? '', + $data['secretComment'] ?? '', + $data['skipMultilineEncoding'] ?? false, + $data['secretPath'] ?? '', + $data['secretValueHidden'] ?? false, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'secretKey' => $this->secretKey, + 'secretValue' => $this->secretValue, + ]; + } +} diff --git a/src/Models/SecretImport.php b/src/Models/SecretImport.php new file mode 100644 index 0000000..1987a79 --- /dev/null +++ b/src/Models/SecretImport.php @@ -0,0 +1,49 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + $data['secretPath'] ?? '', + $data['environment'] ?? '', + $data['folderId'] ?? '', + array_map(fn($secretData) => Secret::fromArray($secretData), $data['secrets'] ?? []), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'secretPath' => $this->secretPath, + 'environment' => $this->environment, + 'folderId' => $this->folderId, + 'secrets' => array_map(fn($secret) => $secret->toArray(), $this->secrets), + ]; + } +} diff --git a/src/Models/UpdateSecretParameters.php b/src/Models/UpdateSecretParameters.php new file mode 100644 index 0000000..abed330 --- /dev/null +++ b/src/Models/UpdateSecretParameters.php @@ -0,0 +1,62 @@ + + */ + public function toArray(): array + { + $params = []; + + if ($this->environment !== null) { + $params['environment'] = $this->environment; + } + + if ($this->projectId !== null) { + $params['workspaceId'] = $this->projectId; + } + + if ($this->secretPath !== null) { + $params['secretPath'] = $this->secretPath; + } + + if ($this->newSecretValue !== null) { + $params['secretValue'] = $this->newSecretValue; + } + + if ($this->newSecretComment !== null) { + $params['secretComment'] = $this->newSecretComment; + } + + if ($this->newSecretKey !== null) { + $params['newSecretName'] = $this->newSecretKey; + } + + if ($this->secretKey !== null) { + $params['secretKey'] = $this->secretKey; + } + + return $params; + } +} diff --git a/src/Services/AuthService.php b/src/Services/AuthService.php new file mode 100644 index 0000000..b8fe629 --- /dev/null +++ b/src/Services/AuthService.php @@ -0,0 +1,39 @@ +httpClient = $httpClient; + $this->onAuthenticate = $onAuthenticate; + } + + /** + * Get the universal auth service + * + * @return UniversalAuthService + */ + public function universalAuth(): UniversalAuthService + { + return new UniversalAuthService($this->httpClient, $this->onAuthenticate); + } +} diff --git a/src/Services/SecretsService.php b/src/Services/SecretsService.php new file mode 100644 index 0000000..f78d49b --- /dev/null +++ b/src/Services/SecretsService.php @@ -0,0 +1,158 @@ +httpClient = $httpClient; + } + + /** + * List secrets with optional filtering and pagination + * + * @param ListSecretsParameters|null $parameters Optional parameters for filtering and pagination + * @return array Array of Secret objects + */ + public function list(?ListSecretsParameters $parameters = null): array + { + $response = $this->httpClient->get('/api/v3/secrets/raw', $parameters?->toArray() ?? []); + $responseData = json_decode($response->getBody()->getContents(), true); + + + $secrets = array_map(fn($data) => Secret::fromArray($data), $responseData['secrets'] ?? []); + + // Ensure unique secrets by key before processing imports + if ($parameters?->recursive) { + $secrets = $this->ensureUniqueSecretsByKey($secrets, $parameters?->skipUniqueValidation ?? false); + } + + + // Handle imports - imports take precedence over secrets + if (isset($responseData['imports'])) { + foreach ($responseData['imports'] as $importBlock) { + foreach ($importBlock['secrets'] as $importSecretData) { + $importSecret = Secret::fromArray($importSecretData); + + // Only append if not already in the list (imports take precedence) + if (!$this->containsSecret($secrets, $importSecret->secretKey)) { + $secrets[] = $importSecret; + } + } + } + } + + if ($parameters?->attachToProcessEnv) { + foreach ($secrets as $secret) { + putenv($secret->secretKey . '=' . $secret->secretValue); + } + } + + return $secrets; + } + + /** + * Get a single secret by key + * + * @param GetSecretParameters|null $parameters Optional parameters for filtering and pagination + * @return Secret The secret + */ + public function get(?GetSecretParameters $parameters = null): Secret + { + if ($parameters?->secretKey === null) { + throw new \InvalidArgumentException('secretKey is required'); + } + + $response = $this->httpClient->get('/api/v3/secrets/raw/' . urlencode($parameters->secretKey), $parameters->toArray()); + $responseData = json_decode($response->getBody()->getContents(), true); + return Secret::fromArray($responseData['secret'] ?? []); + } + + public function update(?UpdateSecretParameters $parameters = null): Secret + { + if ($parameters?->secretKey === null) { + throw new \InvalidArgumentException('secretKey is required'); + } + + $response = $this->httpClient->patch('/api/v3/secrets/raw/' . urlencode($parameters->secretKey), $parameters->toArray()); + $responseData = json_decode($response->getBody()->getContents(), true); + + return Secret::fromArray($responseData['secret'] ?? []); + } + + public function delete(?DeleteSecretParameters $parameters = null): Secret + { + if ($parameters?->secretKey === null) { + throw new \InvalidArgumentException('secretKey is required'); + } + + $response = $this->httpClient->delete('/api/v3/secrets/raw/' . urlencode($parameters->secretKey), $parameters->toArray()); + $responseData = json_decode($response->getBody()->getContents(), true); + return Secret::fromArray($responseData['secret'] ?? []); + } + + public function create(?CreateSecretParameters $parameters = null): Secret + { + if ($parameters?->secretKey === null) { + throw new \InvalidArgumentException('secretKey is required'); + } + + $response = $this->httpClient->post('/api/v3/secrets/raw/' . urlencode($parameters->secretKey), $parameters->toArray()); + $responseData = json_decode($response->getBody()->getContents(), true); + return Secret::fromArray($responseData['secret'] ?? []); + } + + /** + * @param Secret[] $secrets + */ + private function containsSecret(array $secrets, string $secretKey): bool + { + foreach ($secrets as $secret) { + if ($secret->secretKey === $secretKey) { + return true; + } + } + return false; + } + + /** + * @param Secret[] $secrets + * @return Secret[] + */ + private function ensureUniqueSecretsByKey(array $secrets, bool $skipUniqueValidation): array + { + $secretMap = []; + + // Move secrets to a map to ensure uniqueness + foreach ($secrets as $secret) { + if ($skipUniqueValidation) { + // Create a composite key using both secretPath and secretKey + $key = $secret->secretPath . ':' . $secret->secretKey; + } else { + // Use only secretKey for global uniqueness + $key = $secret->secretKey; + } + $secretMap[$key] = $secret; + } + + // Return array with unique secrets + return array_values($secretMap); + } + +} diff --git a/src/Services/UniversalAuthService.php b/src/Services/UniversalAuthService.php new file mode 100644 index 0000000..d137bfb --- /dev/null +++ b/src/Services/UniversalAuthService.php @@ -0,0 +1,58 @@ +httpClient = $httpClient; + $this->onAuthenticate = $onAuthenticate; + } + + /** + * Login with client ID and client secret + * + * @param string $clientId The client ID + * @param string $clientSecret The client secret + * @return MachineIdentityCredential The machine identity credential + */ + public function login(string $clientId, string $clientSecret): MachineIdentityCredential + { + $response = $this->httpClient->post( + "/api/v1/auth/universal-auth/login", [ + 'clientId' => $clientId, + 'clientSecret' => $clientSecret, + ] + ); + + // Parse the JSON response body + $responseData = json_decode($response->getBody()->getContents(), true); + + if (!$responseData) { + throw new \RuntimeException('Invalid response from authentication server'); + } + + $credential = MachineIdentityCredential::fromArray($responseData); + call_user_func($this->onAuthenticate, $credential->accessToken); + + return $credential; + } +}