Ctrl K
ComunidadGuías…

app/Traits/HasSecretManager.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

namespace App\Traits;

use App\Exceptions\RemoteSecretException;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\SecretManagerLink;
use App\Models\Service;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use App\Support\RemoteSecretReferences;
use App\Support\RemoteSecretValueFormatter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Throwable;

trait HasSecretManager
{
    /** @var array<string, string>|null */
    private ?array $resolvedSecretManagerValues = null;

    public static function bootHasSecretManager(): void
    {
        // Return nothing: a non-null result halts the "deleting" event and skips later listeners.
        static::deleting(function ($resource): void {
            $resource->secretManagerLink()->delete();
        });
    }

    public function secretManagerLink(): MorphOne
    {
        return $this->morphOne(SecretManagerLink::class, 'resourceable');
    }

    /**
     * Give a cloned resource the same secret manager source. The token is team-scoped, so a clone in another team gets no link.
     */
    public function cloneSecretManagerLinkTo(Model $target): void
    {
        $link = $this->secretManagerLink()->with('integrationToken')->first();
        // fresh(): a replicated model keeps the relations of its source, such as the old environment.
        $targetTeamId = $target->fresh()?->team()?->id;

        if (! $link || $targetTeamId === null || (int) $link->integrationToken?->team_id !== (int) $targetTeamId) {
            return;
        }

        $target->secretManagerLink()->create([
            'integration_token_id' => $link->integration_token_id,
            'settings' => $link->settings,
        ]);
    }

    /**
     * The value for an `environment:` entry of a generated compose file (standalone databases).
     */
    public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
    {
        $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);

        return $this->formatEnvironmentVariableValue($environmentVariable, $value);
    }

    /**
     * The value for a line of a dotenv file that Docker Compose reads (the service .env file).
     */
    public function resolveSecretManagerDotenvValue(EnvironmentVariable $environmentVariable): ?string
    {
        $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);

        if ($value !== null && $this->environmentVariableUsesSecretManager($environmentVariable)) {
            return RemoteSecretValueFormatter::dotenv($value);
        }

        return $value === null ? null : ($this->useExactEscaping($environmentVariable)
            ? escapeComposeEnvFileValue($value, $this->environmentVariableAllowsInterpolation($environmentVariable, $value))
            : $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value));
    }

    /**
     * Formats a resolved value for an `environment:` entry of a generated compose file. Remote secret
     * values are used exactly as they are; other values keep their existing format.
     */
    public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string
    {
        if ($value !== null && $this->environmentVariableUsesSecretManager($environmentVariable)) {
            return RemoteSecretValueFormatter::composeFile($value);
        }

        return $this->formatLocalEnvironmentVariableValue($environmentVariable, $value);
    }

    /**
     * Formats a resolved value that is placed directly in a generated compose file, such as a
     * password in a command or healthcheck. Remote secret values are escaped for compose
     * interpolation; other values stay unchanged, as before.
     */
    public function formatComposeFileValue(EnvironmentVariable $environmentVariable, string $value): string
    {
        if ($this->environmentVariableUsesSecretManager($environmentVariable)) {
            return RemoteSecretValueFormatter::composeFile($value);
        }

        if ($environmentVariable->key === 'REDIS_PASSWORD' && $this->useExactEscaping($environmentVariable)
            && ($this instanceof StandaloneRedis || $this instanceof StandaloneKeydb || $this instanceof StandaloneDragonfly)) {
            return escapeDollarSign($value);
        }

        return $this->composeCommandValue($environmentVariable, $value);
    }

    /**
     * Resolves every remote secret reference of the given variables now, so that an unreachable
     * secret manager or a missing key stops an operation before it stops the running resource.
     * The fetched secrets stay cached on this model for the start that follows.
     *
     * @param  iterable<EnvironmentVariable>  $environmentVariables
     *
     * @throws RemoteSecretException
     */
    public function ensureRemoteSecretsResolvable(iterable $environmentVariables): void
    {
        foreach ($environmentVariables as $environmentVariable) {
            $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
        }
    }

    private function formatLocalEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string
    {
        if ($value === null) {
            return null;
        }

        if (! $this->useExactEscaping($environmentVariable)) {
            return $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value);
        }

        return $this->environmentVariableAllowsInterpolation($environmentVariable, $value)
            ? $value
            : escapeDollarSign($value);
    }

    /**
     * Decides the escaping of a variable for all paths (service .env, database environment,
     * Redis password). A variable saved before exact escaping keeps the old escaping, so its
     * container gets the same value as before the upgrade. Saving the variable again switches
     * it to exact escaping. Compose references that got the stored value before the upgrade
     * (such as `FOO=${FOO:-}`) use exact escaping, so they keep getting that value. So do old
     * .env lines that Docker Compose cannot read, because they stop the deployment.
     */
    public function useExactEscaping(EnvironmentVariable $environmentVariable): bool
    {
        if (! $environmentVariable->uses_legacy_escaping) {
            return true;
        }

        if (! $this instanceof Service && ! $this instanceof Application) {
            return false;
        }

        if ($this->legacyEnvFileLineIsUnreadable($environmentVariable)) {
            return true;
        }

        $parsesCompose = $this instanceof Service
            || ($this->build_pack === 'dockercompose' && ! $this->settings?->is_raw_compose_deployment_enabled);

        return $parsesCompose
            && dockerComposeVariablesWithStoredValueBeforeExactEscaping($this->docker_compose_raw)->contains($environmentVariable->key);
    }

    /**
     * The old escaping writes JSON objects and arrays without quotes, so a JSON value with a line
     * break breaks the whole .env file (#8279). It puts other literal and multiline values in single
     * quotes without escaping, and Docker Compose ends the value at the next quote.
     */
    private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool
    {
        $value = $this->resolvedEnvironmentVariableValue($environmentVariable);

        if (! is_string($value)) {
            return false;
        }

        if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
            return str_contains($value, "\n");
        }

        return ($environmentVariable->is_literal || $environmentVariable->is_multiline) && str_contains($value, "'");
    }

    /**
     * A variable value for a Docker Compose command or healthcheck. Compose interpolates `$` there
     * too, so the value gets the same `$` escaping as in the environment list, and the command uses
     * the same value as the container.
     */
    public function composeCommandValue(EnvironmentVariable $environmentVariable, string $value): string
    {
        if (! $this->useExactEscaping($environmentVariable) || $this->environmentVariableAllowsInterpolation($environmentVariable, $value)) {
            return $value;
        }

        return escapeDollarSign($value);
    }

    /**
     * The escaping used before exact escaping. Variables saved before the upgrade keep it,
     * so their containers get the same values as before.
     */
    public function legacyFormatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, string $value): string
    {
        if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
            return $value;
        }

        return $environmentVariable->is_literal || $environmentVariable->is_multiline
            ? "'{$value}'"
            : escapeEnvVariables($value);
    }

    /**
     * Whether the container gets a different value than the saved value, because this variable
     * uses the old escaping. Secret manager values are not fetched here, so they never show it.
     */
    public function legacyEscapingChangesValue(EnvironmentVariable $environmentVariable): bool
    {
        if ($this->useExactEscaping($environmentVariable)) {
            return false;
        }

        $value = $this->resolvedEnvironmentVariableValue($environmentVariable);

        if (blank($value) || RemoteSecretReferences::containsReference($value)) {
            return false;
        }

        $isRedisPassword = $environmentVariable->key === 'REDIS_PASSWORD'
            && ($this instanceof StandaloneRedis || $this instanceof StandaloneKeydb || $this instanceof StandaloneDragonfly);

        if ($isRedisPassword && str_contains($value, '$')) {
            return true;
        }

        if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
            return str_contains($value, '$') || (! $this instanceof Application && str_contains($value, "\n"));
        }

        if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
            // Service and application .env files strip the old single quotes (values with a quote use exact
            // escaping); database environment lists keep them.
            return ! ($this instanceof Service || $this instanceof Application);
        }

        return escapeEnvVariables($value) !== $value
            || (($this instanceof Service || $this instanceof Application) && str_contains($value, ' #'));
    }

    /**
     * Only plain values allow Compose $VAR interpolation.
     */
    public function environmentVariableAllowsInterpolation(EnvironmentVariable $environmentVariable, string $value): bool
    {
        $isJson = json_validate($value) && in_array(ltrim($value)[0] ?? '', ['{', '['], true);

        return ! $isJson
            && ! $environmentVariable->is_literal
            && ! $environmentVariable->is_multiline
            && ! $this->environmentVariableUsesSecretManager($environmentVariable);
    }

    public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
    {
        $value = $this->resolvedEnvironmentVariableValue($environmentVariable);

        if ($value === null) {
            return null;
        }

        if ($this->resolvesRemoteSecretReferences($environmentVariable, $value)) {
            $secrets = $this->secretManagerValues();
            $missing = RemoteSecretReferences::missingKeys($value, $secrets);

            if ($missing !== []) {
                throw new RemoteSecretException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key}).");
            }

            $value = RemoteSecretReferences::substitute($value, $secrets);
        }

        return $value;
    }

    public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool
    {
        return $this->resolvesRemoteSecretReferences(
            $environmentVariable,
            $this->resolvedEnvironmentVariableValue($environmentVariable),
        );
    }

    /**
     * Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed.
     */
    private function resolvesRemoteSecretReferences(EnvironmentVariable $environmentVariable, ?string $value): bool
    {
        if (! RemoteSecretReferences::containsReference($value)) {
            return false;
        }

        return ! $environmentVariable->is_literal
            || $this->resolvedSecretManagerValues !== null
            || $this->secretManagerLink()->exists();
    }

    private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
    {
        return $environmentVariable->get_real_environment_variables_with_server(
            $environmentVariable->value,
            $this,
            data_get($this, 'server'),
        );
    }

    /** @return array<string, string> */
    private function secretManagerValues(): array
    {
        if ($this->resolvedSecretManagerValues !== null) {
            return $this->resolvedSecretManagerValues;
        }

        $link = $this->secretManagerLink()->with('integrationToken')->first();

        if (! $link) {
            throw new RemoteSecretException('Environment variables reference remote secrets, but no secret manager source is configured.');
        }

        try {
            return $this->resolvedSecretManagerValues = $link->fetchSecrets();
        } catch (RemoteSecretException $e) {
            throw $e;
        } catch (Throwable $e) {
            throw new RemoteSecretException('Could not fetch remote secrets: '.$e->getMessage(), previous: $e);
        }
    }

    /** @return array<string, string> */
    public function resolvedSecretManagerValuesForRedaction(): array
    {
        return $this->resolvedSecretManagerValues ?? [];
    }
}