lthn.io/app/Core/Config/Models/ConfigValue.php

276 lines
7.3 KiB
PHP
Raw Permalink Normal View History

<?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();
}
}