276 lines
7.3 KiB
PHP
276 lines
7.3 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Core PHP Framework
|
||
|
|
*
|
||
|
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||
|
|
* See LICENSE file for details.
|
||
|
|
*/
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Core\Config\Models;
|
||
|
|
|
||
|
|
use Carbon\Carbon;
|
||
|
|
use Core\Config\ConfigResolver;
|
||
|
|
use Core\Config\Enums\ScopeType;
|
||
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||
|
|
use Illuminate\Database\Eloquent\Collection;
|
||
|
|
use Illuminate\Database\Eloquent\Model;
|
||
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
|
use Illuminate\Support\Facades\Crypt;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Configuration value (junction table).
|
||
|
|
*
|
||
|
|
* Links profiles to keys with actual values.
|
||
|
|
* The `locked` flag implements FINAL - prevents child override.
|
||
|
|
* The `channel_id` adds voice/context dimension to scoping.
|
||
|
|
*
|
||
|
|
* @property int $id
|
||
|
|
* @property int $profile_id
|
||
|
|
* @property int $key_id
|
||
|
|
* @property int|null $channel_id
|
||
|
|
* @property mixed $value
|
||
|
|
* @property bool $locked
|
||
|
|
* @property int|null $inherited_from
|
||
|
|
* @property Carbon $created_at
|
||
|
|
* @property Carbon $updated_at
|
||
|
|
*/
|
||
|
|
class ConfigValue extends Model
|
||
|
|
{
|
||
|
|
protected $table = 'config_values';
|
||
|
|
|
||
|
|
protected $fillable = [
|
||
|
|
'profile_id',
|
||
|
|
'key_id',
|
||
|
|
'channel_id',
|
||
|
|
'value',
|
||
|
|
'locked',
|
||
|
|
'inherited_from',
|
||
|
|
];
|
||
|
|
|
||
|
|
protected $casts = [
|
||
|
|
'locked' => 'boolean',
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Encrypted value marker prefix.
|
||
|
|
*
|
||
|
|
* Used to detect if a stored value is encrypted.
|
||
|
|
*/
|
||
|
|
protected const ENCRYPTED_PREFIX = 'encrypted:';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the value attribute with automatic decryption for sensitive keys.
|
||
|
|
*/
|
||
|
|
public function getValueAttribute(mixed $value): mixed
|
||
|
|
{
|
||
|
|
if ($value === null) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode JSON first
|
||
|
|
$decoded = is_string($value) ? json_decode($value, true) : $value;
|
||
|
|
|
||
|
|
// Check if this is an encrypted value
|
||
|
|
if (is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX)) {
|
||
|
|
try {
|
||
|
|
$encrypted = substr($decoded, strlen(self::ENCRYPTED_PREFIX));
|
||
|
|
|
||
|
|
return json_decode(Crypt::decryptString($encrypted), true);
|
||
|
|
} catch (DecryptException) {
|
||
|
|
// Return null if decryption fails (key rotation, corruption, etc.)
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $decoded;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set the value attribute with automatic encryption for sensitive keys.
|
||
|
|
*/
|
||
|
|
public function setValueAttribute(mixed $value): void
|
||
|
|
{
|
||
|
|
// Check if the key is sensitive (need to load it if not already)
|
||
|
|
$key = $this->relationLoaded('key')
|
||
|
|
? $this->getRelation('key')
|
||
|
|
: ($this->key_id ? ConfigKey::find($this->key_id) : null);
|
||
|
|
|
||
|
|
if ($key?->isSensitive() && $value !== null) {
|
||
|
|
// Encrypt the value
|
||
|
|
$jsonValue = json_encode($value);
|
||
|
|
$encrypted = Crypt::encryptString($jsonValue);
|
||
|
|
$this->attributes['value'] = json_encode(self::ENCRYPTED_PREFIX.$encrypted);
|
||
|
|
} else {
|
||
|
|
// Store as regular JSON
|
||
|
|
$this->attributes['value'] = json_encode($value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if the current stored value is encrypted.
|
||
|
|
*/
|
||
|
|
public function isEncrypted(): bool
|
||
|
|
{
|
||
|
|
$raw = $this->attributes['value'] ?? null;
|
||
|
|
if ($raw === null) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$decoded = json_decode($raw, true);
|
||
|
|
|
||
|
|
return is_string($decoded) && str_starts_with($decoded, self::ENCRYPTED_PREFIX);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The profile this value belongs to.
|
||
|
|
*/
|
||
|
|
public function profile(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(ConfigProfile::class, 'profile_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The key this value is for.
|
||
|
|
*/
|
||
|
|
public function key(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(ConfigKey::class, 'key_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Profile this value was inherited from (if any).
|
||
|
|
*/
|
||
|
|
public function inheritedFromProfile(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(ConfigProfile::class, 'inherited_from');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The channel this value is scoped to (null = all channels).
|
||
|
|
*/
|
||
|
|
public function channel(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(Channel::class, 'channel_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get typed value.
|
||
|
|
*/
|
||
|
|
public function getTypedValue(): mixed
|
||
|
|
{
|
||
|
|
$key = $this->key;
|
||
|
|
|
||
|
|
if ($key === null) {
|
||
|
|
return $this->value;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $key->type->cast($this->value);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if this value is locked (FINAL).
|
||
|
|
*/
|
||
|
|
public function isLocked(): bool
|
||
|
|
{
|
||
|
|
return $this->locked;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if this value was inherited.
|
||
|
|
*/
|
||
|
|
public function isInherited(): bool
|
||
|
|
{
|
||
|
|
return $this->inherited_from !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find value for a profile, key, and optional channel.
|
||
|
|
*/
|
||
|
|
public static function findValue(int $profileId, int $keyId, ?int $channelId = null): ?self
|
||
|
|
{
|
||
|
|
return static::where('profile_id', $profileId)
|
||
|
|
->where('key_id', $keyId)
|
||
|
|
->where('channel_id', $channelId)
|
||
|
|
->first();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set or update a value.
|
||
|
|
*
|
||
|
|
* Automatically invalidates the resolved hash so the next read
|
||
|
|
* will recompute the value with the new setting.
|
||
|
|
*/
|
||
|
|
public static function setValue(
|
||
|
|
int $profileId,
|
||
|
|
int $keyId,
|
||
|
|
mixed $value,
|
||
|
|
bool $locked = false,
|
||
|
|
?int $inheritedFrom = null,
|
||
|
|
?int $channelId = null,
|
||
|
|
): self {
|
||
|
|
$configValue = static::updateOrCreate(
|
||
|
|
[
|
||
|
|
'profile_id' => $profileId,
|
||
|
|
'key_id' => $keyId,
|
||
|
|
'channel_id' => $channelId,
|
||
|
|
],
|
||
|
|
[
|
||
|
|
'value' => $value,
|
||
|
|
'locked' => $locked,
|
||
|
|
'inherited_from' => $inheritedFrom,
|
||
|
|
]
|
||
|
|
);
|
||
|
|
|
||
|
|
// Invalidate hash for this key (all scopes)
|
||
|
|
// The value will be recomputed on next access
|
||
|
|
$key = ConfigKey::find($keyId);
|
||
|
|
if ($key === null) {
|
||
|
|
return $configValue;
|
||
|
|
}
|
||
|
|
|
||
|
|
ConfigResolver::clear($key->code);
|
||
|
|
|
||
|
|
// Also clear from resolved table for this scope
|
||
|
|
$profile = ConfigProfile::find($profileId);
|
||
|
|
if ($profile === null) {
|
||
|
|
return $configValue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$workspaceId = $profile->scope_type === ScopeType::WORKSPACE
|
||
|
|
? $profile->scope_id
|
||
|
|
: null;
|
||
|
|
|
||
|
|
ConfigResolved::where('key_code', $key->code)
|
||
|
|
->where('workspace_id', $workspaceId)
|
||
|
|
->where('channel_id', $channelId)
|
||
|
|
->delete();
|
||
|
|
|
||
|
|
return $configValue;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get all values for a key across profiles and channels.
|
||
|
|
*
|
||
|
|
* Used for batch resolution to avoid N+1.
|
||
|
|
*
|
||
|
|
* @param array<int> $profileIds
|
||
|
|
* @param array<int>|null $channelIds Include null for "all channels" values
|
||
|
|
* @return Collection<int, self>
|
||
|
|
*/
|
||
|
|
public static function forKeyInProfiles(int $keyId, array $profileIds, ?array $channelIds = null): Collection
|
||
|
|
{
|
||
|
|
return static::where('key_id', $keyId)
|
||
|
|
->whereIn('profile_id', $profileIds)
|
||
|
|
->when($channelIds !== null, function ($query) use ($channelIds) {
|
||
|
|
$query->where(function ($q) use ($channelIds) {
|
||
|
|
$q->whereIn('channel_id', $channelIds)
|
||
|
|
->orWhereNull('channel_id');
|
||
|
|
});
|
||
|
|
})
|
||
|
|
->get();
|
||
|
|
}
|
||
|
|
}
|