Files
2026-07-04 16:00:04 +02:00

1066 lines
36 KiB
PHP

<?php
declare(strict_types=1);
final class PostgresService
{
/** @var array{host:string,port:int,database:string,user:string,password:string} */
private array $credentials;
private Settings $settings;
/** @var array<string,resource> */
private array $connections = [];
public function __construct(array $credentials, Settings $settings)
{
$database = $credentials['database'] ?? 'postgres';
if ($database === '*' || trim((string)$database) === '') {
$database = 'postgres';
}
$this->credentials = [
'host' => (string)($credentials['host'] ?? 'localhost'),
'port' => (int)($credentials['port'] ?? 5432),
'database' => (string)$database,
'user' => (string)($credentials['user'] ?? 'postgres'),
'password' => (string)($credentials['password'] ?? ''),
];
$this->settings = $settings;
}
public function ensureMetadataSchema(): void
{
$this->query('CREATE SCHEMA IF NOT EXISTS da_plugin');
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin.access_hosts (
da_user text NOT NULL,
db_name text NOT NULL DEFAULT \'\',
role_name text NOT NULL,
host_pattern text NOT NULL,
note text NOT NULL DEFAULT \'\',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (role_name, db_name, host_pattern)
)'
);
$this->query('ALTER TABLE da_plugin.access_hosts ADD COLUMN IF NOT EXISTS db_name text NOT NULL DEFAULT \'\'');
$this->query('ALTER TABLE da_plugin.access_hosts DROP CONSTRAINT IF EXISTS access_hosts_pkey');
$this->query('ALTER TABLE da_plugin.access_hosts ADD CONSTRAINT access_hosts_pkey PRIMARY KEY (role_name, db_name, host_pattern)');
$this->query('ALTER TABLE da_plugin.access_hosts ADD COLUMN IF NOT EXISTS note text NOT NULL DEFAULT \'\'');
$this->query('CREATE INDEX IF NOT EXISTS access_hosts_da_user_idx ON da_plugin.access_hosts (da_user)');
$this->query('CREATE INDEX IF NOT EXISTS access_hosts_da_user_db_idx ON da_plugin.access_hosts (da_user, db_name)');
$this->query(
'CREATE TABLE IF NOT EXISTS da_plugin.grant_profiles (
db_name text NOT NULL,
role_name text NOT NULL,
privileges text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (db_name, role_name)
)'
);
}
/**
* @return array{host:string,port:int}
*/
public function connectionEndpoint(): array
{
return [
'host' => $this->credentials['host'],
'port' => (int)$this->credentials['port'],
];
}
public function serverPort(): int
{
$row = $this->fetchOne('SHOW port');
$port = isset($row['port']) ? (int)$row['port'] : 0;
if ($port > 0) {
return $port;
}
return (int)$this->credentials['port'];
}
public function serverVersion(): string
{
$row = $this->fetchOne('SHOW server_version');
if (isset($row['server_version'])) {
return trim((string)$row['server_version']);
}
$row = $this->fetchOne('SELECT version() AS version');
if (isset($row['version'])) {
return trim((string)$row['version']);
}
return '';
}
public function countDatabasesForUser(string $daUser): int
{
$row = $this->fetchOne(
'SELECT COUNT(*)::int AS cnt
FROM pg_database
WHERE datistemplate = false AND datname LIKE $1 ESCAPE \'\\\'',
[$this->likePattern($daUser . '_')]
);
return isset($row['cnt']) ? (int)$row['cnt'] : 0;
}
/**
* @return array<int,array{name:string,owner:string,size:string}>
*/
public function listDatabasesForUser(string $daUser, bool $includeSize = true): array
{
if ($includeSize) {
$rows = $this->fetchAll(
'SELECT d.datname AS name,
pg_get_userbyid(d.datdba) AS owner,
pg_size_pretty(pg_database_size(d.datname)) AS size
FROM pg_database d
WHERE d.datistemplate = false AND d.datname LIKE $1 ESCAPE \'\\\'
ORDER BY d.datname',
[$this->likePattern($daUser . '_')]
);
} else {
$rows = $this->fetchAll(
'SELECT d.datname AS name,
pg_get_userbyid(d.datdba) AS owner,
\'\'::text AS size
FROM pg_database d
WHERE d.datistemplate = false AND d.datname LIKE $1 ESCAPE \'\\\'
ORDER BY d.datname',
[$this->likePattern($daUser . '_')]
);
}
$out = [];
foreach ($rows as $row) {
$out[] = [
'name' => (string)$row['name'],
'owner' => (string)$row['owner'],
'size' => (string)$row['size'],
];
}
return $out;
}
/**
* @return array<int,array{name:string,can_login:bool}>
*/
public function listRolesForUser(string $daUser): array
{
$rows = $this->fetchAll(
'SELECT rolname AS name, rolcanlogin AS can_login
FROM pg_roles
WHERE rolname LIKE $1 ESCAPE \'\\\'
ORDER BY rolname',
[$this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$out[] = [
'name' => (string)$row['name'],
'can_login' => ($row['can_login'] === 't' || $row['can_login'] === true),
];
}
return $out;
}
/**
* @return array<string,array<int,string>>
*/
public function listDatabaseUsersMap(string $daUser): array
{
$rows = $this->fetchAll(
'SELECT d.datname AS db_name, r.rolname AS role_name
FROM pg_database d
JOIN pg_roles r ON r.rolname LIKE $1 ESCAPE \'\\\'
WHERE d.datistemplate = false
AND d.datname LIKE $2 ESCAPE \'\\\'
AND (
d.datdba = r.oid
OR EXISTS (
SELECT 1
FROM aclexplode(COALESCE(d.datacl, acldefault(\'d\', d.datdba))) AS acl
WHERE acl.grantee = r.oid
AND acl.privilege_type = \'CONNECT\'
)
)
ORDER BY d.datname, r.rolname',
[$this->likePattern($daUser . '_'), $this->likePattern($daUser . '_')]
);
$map = [];
foreach ($rows as $row) {
$db = (string)$row['db_name'];
$role = (string)$row['role_name'];
if (!isset($map[$db])) {
$map[$db] = [];
}
$map[$db][] = $role;
}
return $map;
}
/**
* @return array<string,array<int,string>>
*/
public function listRoleDatabasesMap(string $daUser): array
{
$dbMap = $this->listDatabaseUsersMap($daUser);
$out = [];
foreach ($dbMap as $db => $roles) {
foreach ($roles as $role) {
if (!isset($out[$role])) {
$out[$role] = [];
}
$out[$role][] = $db;
}
}
ksort($out);
return $out;
}
/**
* @return string[]
*/
public function listDatabaseUsers(string $daUser, string $database): array
{
$rows = $this->fetchAll(
'SELECT r.rolname AS role_name
FROM pg_roles r
JOIN pg_database d ON d.datname = $2
WHERE r.rolname LIKE $1 ESCAPE \'\\\'
AND d.datistemplate = false
AND (
d.datdba = r.oid
OR EXISTS (
SELECT 1
FROM aclexplode(COALESCE(d.datacl, acldefault(\'d\', d.datdba))) AS acl
WHERE acl.grantee = r.oid
AND acl.privilege_type = \'CONNECT\'
)
)
ORDER BY r.rolname',
[$this->likePattern($daUser . '_'), $database]
);
$out = [];
foreach ($rows as $row) {
$out[] = (string)$row['role_name'];
}
return $out;
}
public function roleExists(string $role): bool
{
$row = $this->fetchOne('SELECT 1 AS ok FROM pg_roles WHERE rolname = $1', [$role]);
return !empty($row);
}
public function databaseExists(string $dbName): bool
{
$row = $this->fetchOne('SELECT 1 AS ok FROM pg_database WHERE datname = $1', [$dbName]);
return !empty($row);
}
public function getDatabaseOwner(string $dbName): ?string
{
$row = $this->fetchOne('SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = $1', [$dbName]);
if (empty($row['owner'])) {
return null;
}
return (string)$row['owner'];
}
/**
* @return array{name:string,owner:string,size:string,encoding:string,collation:string}
*/
public function getDatabaseInfo(string $dbName): array
{
$row = $this->fetchOne(
'SELECT d.datname AS name,
pg_get_userbyid(d.datdba) AS owner,
pg_size_pretty(pg_database_size(d.datname)) AS size,
pg_encoding_to_char(d.encoding) AS encoding,
d.datcollate AS collation
FROM pg_database d
WHERE d.datname = $1',
[$dbName]
);
if (empty($row)) {
throw new RuntimeException('Nie znaleziono bazy danych: ' . $dbName);
}
return [
'name' => (string)($row['name'] ?? $dbName),
'owner' => (string)($row['owner'] ?? ''),
'size' => (string)($row['size'] ?? '0 B'),
'encoding' => (string)($row['encoding'] ?? 'UTF8'),
'collation' => (string)($row['collation'] ?? ''),
];
}
/**
* @return array{tables:int,views:int,triggers:int,routines:int}
*/
public function getDatabaseObjectStats(string $dbName): array
{
$row = $this->fetchOne(
'SELECT
(SELECT COUNT(*)::int FROM information_schema.tables WHERE table_schema = \'public\' AND table_type = \'BASE TABLE\') AS tables,
(SELECT COUNT(*)::int FROM information_schema.views WHERE table_schema = \'public\') AS views,
(SELECT COUNT(*)::int FROM information_schema.triggers WHERE trigger_schema = \'public\') AS triggers,
(SELECT COUNT(*)::int FROM information_schema.routines WHERE specific_schema = \'public\') AS routines',
[],
$dbName
);
return [
'tables' => (int)($row['tables'] ?? 0),
'views' => (int)($row['views'] ?? 0),
'triggers' => (int)($row['triggers'] ?? 0),
'routines' => (int)($row['routines'] ?? 0),
];
}
/**
* @return string[]
*/
public function roleOwnedDatabases(string $role, string $daUser): array
{
$rows = $this->fetchAll(
'SELECT datname AS name
FROM pg_database
WHERE datistemplate = false
AND pg_get_userbyid(datdba) = $1
AND datname LIKE $2 ESCAPE \'\\\'
ORDER BY datname',
[$role, $this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$out[] = (string)$row['name'];
}
return $out;
}
/**
* @return string[]
*/
public function roleAssignedDatabases(string $role, string $daUser): array
{
$rows = $this->fetchAll(
'SELECT datname AS name
FROM pg_database d
JOIN pg_roles r ON r.rolname = $1
WHERE d.datistemplate = false
AND d.datname LIKE $2 ESCAPE \'\\\'
AND (
d.datdba = r.oid
OR EXISTS (
SELECT 1
FROM aclexplode(COALESCE(d.datacl, acldefault(\'d\', d.datdba))) AS acl
WHERE acl.grantee = r.oid
AND acl.privilege_type = \'CONNECT\'
)
)
ORDER BY d.datname',
[$role, $this->likePattern($daUser . '_')]
);
$out = [];
foreach ($rows as $row) {
$out[] = (string)$row['name'];
}
return $out;
}
/**
* @return array<int,array{db_name:string,host:string,note:string}>
*/
public function getRoleHostEntries(string $daUser, string $role, string $dbName = ''): array
{
$rows = $this->fetchAll(
'SELECT db_name, host_pattern, note
FROM da_plugin.access_hosts
WHERE da_user = $1 AND role_name = $2 AND db_name = $3
ORDER BY host_pattern',
[$daUser, $role, $dbName]
);
$out = [];
foreach ($rows as $row) {
$out[] = [
'db_name' => (string)($row['db_name'] ?? ''),
'host' => (string)$row['host_pattern'],
'note' => $this->sanitizeHostNote((string)($row['note'] ?? '')),
];
}
return $out;
}
/**
* @return string[]
*/
public function getRoleHosts(string $daUser, string $role): array
{
$entries = $this->getRoleHostEntries($daUser, $role);
$out = [];
foreach ($entries as $entry) {
$out[] = $entry['host'];
}
return $out;
}
/**
* @param string[] $hosts
*/
public function replaceRoleHosts(string $daUser, string $role, array $hosts): void
{
$entries = [];
foreach ($hosts as $host) {
$entries[] = ['host' => (string)$host, 'note' => ''];
}
$this->replaceRoleHostsWithNotes($daUser, $role, $entries);
}
/**
* @param array<int,array{host:string,note:string}> $hostEntries
*/
public function replaceRoleHostsWithNotes(string $daUser, string $role, array $hostEntries, string $dbName = ''): void
{
$normalized = [];
foreach ($hostEntries as $entry) {
$host = trim((string)($entry['host'] ?? ''));
if ($host === '') {
continue;
}
$normalized[$host] = $this->sanitizeHostNote((string)($entry['note'] ?? ''));
}
$this->query(
'DELETE FROM da_plugin.access_hosts WHERE da_user = $1 AND role_name = $2 AND db_name = $3',
[$daUser, $role, $dbName]
);
foreach ($normalized as $host => $note) {
$this->query(
'INSERT INTO da_plugin.access_hosts (da_user, db_name, role_name, host_pattern, note, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (role_name, db_name, host_pattern)
DO UPDATE SET da_user = EXCLUDED.da_user, note = EXCLUDED.note, updated_at = now()',
[$daUser, $dbName, $role, $host, $note]
);
}
}
public function deleteRoleHosts(string $daUser, string $role, ?string $dbName = null): void
{
if ($dbName === null) {
$this->query('DELETE FROM da_plugin.access_hosts WHERE da_user = $1 AND role_name = $2', [$daUser, $role]);
return;
}
$this->query(
'DELETE FROM da_plugin.access_hosts WHERE da_user = $1 AND role_name = $2 AND db_name = $3',
[$daUser, $role, $dbName]
);
}
public function createRole(string $role, string $password): void
{
$qRole = $this->quoteIdentifier($role);
$qPassword = $this->quoteLiteral($password);
$this->query("SET password_encryption = 'scram-sha-256'");
$this->query(
'CREATE ROLE ' . $qRole . ' LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOREPLICATION PASSWORD ' . $qPassword
);
}
public function changeRolePassword(string $role, string $password): void
{
$qRole = $this->quoteIdentifier($role);
$qPassword = $this->quoteLiteral($password);
$this->query("SET password_encryption = 'scram-sha-256'");
$this->query('ALTER ROLE ' . $qRole . ' WITH LOGIN PASSWORD ' . $qPassword);
}
public function createDatabase(string $dbName, string $owner): void
{
$qDb = $this->quoteIdentifier($dbName);
$qOwner = $this->quoteIdentifier($owner);
$this->query('CREATE DATABASE ' . $qDb . ' WITH OWNER ' . $qOwner . " ENCODING 'UTF8' TEMPLATE template0");
$this->enforceDatabaseIsolation($dbName);
}
public function dropDatabase(string $dbName): void
{
$qDb = $this->quoteIdentifier($dbName);
$this->query(
'SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()',
[$dbName]
);
$this->query('DROP DATABASE IF EXISTS ' . $qDb);
}
public function dropRole(string $role): void
{
$qRole = $this->quoteIdentifier($role);
$this->query('DROP ROLE IF EXISTS ' . $qRole);
}
public function reassignOwnedObjects(string $dbName, string $fromRole, string $toRole): void
{
if ($fromRole === $toRole) {
return;
}
$qFrom = $this->quoteIdentifier($fromRole);
$qTo = $this->quoteIdentifier($toRole);
$this->query('REASSIGN OWNED BY ' . $qFrom . ' TO ' . $qTo, [], $dbName);
$this->query('DROP OWNED BY ' . $qFrom, [], $dbName);
}
/**
* @param string[] $privileges
*/
public function grantPrivileges(string $dbName, string $role, array $privileges): void
{
$privileges = array_values(array_unique($privileges));
if (empty($privileges)) {
throw new RuntimeException('Brak uprawnień do nadania.');
}
$this->enforceDatabaseIsolation($dbName);
if (in_array('ALL', $privileges, true)) {
$this->grantAllPrivileges($dbName, $role);
$this->saveGrantProfile($dbName, $role, ['ALL']);
return;
}
$this->revokePrivilegesInternal($dbName, $role, false);
$qDb = $this->quoteIdentifier($dbName);
$qRole = $this->quoteIdentifier($role);
$dbPrivsMap = [
'CONNECT' => 'CONNECT',
'CREATE' => 'CREATE',
'TEMPORARY' => 'TEMPORARY',
];
$dbPrivs = [];
foreach ($dbPrivsMap as $key => $sqlPriv) {
if (in_array($key, $privileges, true)) {
$dbPrivs[] = $sqlPriv;
}
}
if (!empty($dbPrivs)) {
$this->query('GRANT ' . implode(', ', $dbPrivs) . ' ON DATABASE ' . $qDb . ' TO ' . $qRole);
}
$schemaPrivs = [];
if (in_array('SCHEMA_USAGE', $privileges, true)) {
$schemaPrivs[] = 'USAGE';
}
if (in_array('CREATE', $privileges, true)) {
$schemaPrivs[] = 'CREATE';
}
$tablePrivs = [];
$tableMap = [
'TABLE_SELECT' => 'SELECT',
'TABLE_INSERT' => 'INSERT',
'TABLE_UPDATE' => 'UPDATE',
'TABLE_DELETE' => 'DELETE',
'TABLE_TRUNCATE' => 'TRUNCATE',
'TABLE_REFERENCES' => 'REFERENCES',
'TABLE_TRIGGER' => 'TRIGGER',
];
foreach ($tableMap as $key => $sqlPriv) {
if (in_array($key, $privileges, true)) {
$tablePrivs[] = $sqlPriv;
}
}
$sequencePrivs = [];
$seqMap = [
'SEQUENCE_USAGE' => 'USAGE',
'SEQUENCE_SELECT' => 'SELECT',
'SEQUENCE_UPDATE' => 'UPDATE',
];
foreach ($seqMap as $key => $sqlPriv) {
if (in_array($key, $privileges, true)) {
$sequencePrivs[] = $sqlPriv;
}
}
$functionExecute = in_array('FUNCTION_EXECUTE', $privileges, true);
if (!empty($schemaPrivs)) {
$this->query('GRANT ' . implode(', ', $schemaPrivs) . ' ON SCHEMA public TO ' . $qRole, [], $dbName);
}
if (!empty($tablePrivs)) {
$this->query('GRANT ' . implode(', ', $tablePrivs) . ' ON ALL TABLES IN SCHEMA public TO ' . $qRole, [], $dbName);
}
if (!empty($sequencePrivs)) {
$this->query('GRANT ' . implode(', ', $sequencePrivs) . ' ON ALL SEQUENCES IN SCHEMA public TO ' . $qRole, [], $dbName);
}
if ($functionExecute) {
$this->query('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ' . $qRole, [], $dbName);
}
$owner = $this->getDatabaseOwner($dbName);
if ($owner !== null) {
$qOwner = $this->quoteIdentifier($owner);
if (!empty($tablePrivs)) {
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ' . implode(', ', $tablePrivs) . ' ON TABLES TO ' . $qRole,
[],
$dbName
);
}
if (!empty($sequencePrivs)) {
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ' . implode(', ', $sequencePrivs) . ' ON SEQUENCES TO ' . $qRole,
[],
$dbName
);
}
if ($functionExecute) {
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ' . $qRole,
[],
$dbName
);
}
}
sort($privileges);
$this->saveGrantProfile($dbName, $role, $privileges);
}
public function revokePrivileges(string $dbName, string $role): void
{
$this->revokePrivilegesInternal($dbName, $role, true);
}
public function getGrantProfile(string $dbName, string $role): array
{
$row = $this->fetchOne(
'SELECT privileges FROM da_plugin.grant_profiles WHERE db_name = $1 AND role_name = $2',
[$dbName, $role]
);
if (empty($row['privileges'])) {
return [];
}
$parts = array_filter(array_map('trim', explode(',', (string)$row['privileges'])));
$parts = array_map('strtoupper', $parts);
return array_values(array_unique($parts));
}
/**
* @return array{ok:bool,output:string}
*/
public function syncHbaRules(bool $restartPostgresql = false): array
{
$script = $this->settings->hbaSyncScript();
if (!is_file($script)) {
return ['ok' => false, 'output' => 'Brak skryptu synchronizacji pg_hba: ' . $script];
}
$cmd = '/usr/bin/sudo -n ' . escapeshellarg($script);
if ($restartPostgresql) {
$cmd .= ' --restart-postgresql';
}
$cmd .= ' 2>&1';
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($restartPostgresql) {
$this->resetConnections();
if ($exitCode === 0 && !$this->waitForConnectionAfterRestart()) {
$exitCode = 1;
$output[] = 'PostgreSQL został zrestartowany, ale plugin nie odzyskał połączenia w czasie oczekiwania.';
}
}
return [
'ok' => $exitCode === 0,
'output' => trim(implode("\n", $output)),
];
}
public function createTemporaryRole(string $role, string $password, int $expiresAt): void
{
$qRole = $this->quoteIdentifier($role);
$qPassword = $this->quoteLiteral($password);
$validUntil = gmdate('Y-m-d H:i:s+00', max($expiresAt, time() + 60));
$qValidUntil = $this->quoteLiteral($validUntil);
$this->query("SET password_encryption = 'scram-sha-256'");
$this->query(
'CREATE ROLE ' . $qRole .
' LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT NOREPLICATION PASSWORD ' . $qPassword .
' VALID UNTIL ' . $qValidUntil
);
}
public function grantTemporaryAdminerAccess(string $dbName, string $role): void
{
$qDb = $this->quoteIdentifier($dbName);
$qRole = $this->quoteIdentifier($role);
$this->tryQuery('GRANT CONNECT, TEMPORARY ON DATABASE ' . $qDb . ' TO ' . $qRole);
$this->tryQuery('GRANT USAGE ON SCHEMA public TO ' . $qRole, [], $dbName);
$this->tryQuery(
'GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public TO ' . $qRole,
[],
$dbName
);
$this->tryQuery(
'GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO ' . $qRole,
[],
$dbName
);
$this->tryQuery('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ' . $qRole, [], $dbName);
}
public function dropTemporaryRole(string $role): void
{
$qRole = $this->quoteIdentifier($role);
$dbRows = $this->fetchAll(
'SELECT datname AS name
FROM pg_database
WHERE datistemplate = false
ORDER BY datname'
);
foreach ($dbRows as $dbRow) {
$dbName = (string)($dbRow['name'] ?? '');
if ($dbName === '') {
continue;
}
$this->tryQuery('DROP OWNED BY ' . $qRole, [], $dbName);
}
$this->tryQuery('DROP ROLE IF EXISTS ' . $qRole);
}
public function cleanupExpiredAdminerRoles(string $rolePrefix = 'da_tmp_adminer_'): int
{
$rows = $this->fetchAll(
'SELECT rolname AS name
FROM pg_roles
WHERE rolname LIKE $1 ESCAPE \'\\\'
AND rolvaliduntil IS NOT NULL
AND rolvaliduntil < now()
ORDER BY rolname',
[$this->likePattern($rolePrefix)]
);
if (empty($rows)) {
return 0;
}
$removed = 0;
foreach ($rows as $row) {
$role = (string)($row['name'] ?? '');
if ($role === '') {
continue;
}
try {
$this->dropTemporaryRole($role);
if (!$this->roleExists($role)) {
$removed++;
}
} catch (Throwable $e) {
// Ignorujemy błędy cleanupu, aby nie blokować logowania.
}
}
return $removed;
}
private function grantAllPrivileges(string $dbName, string $role): void
{
$qDb = $this->quoteIdentifier($dbName);
$qRole = $this->quoteIdentifier($role);
$this->enforceDatabaseIsolation($dbName);
$this->revokePrivilegesInternal($dbName, $role, false);
$this->query('GRANT ALL PRIVILEGES ON DATABASE ' . $qDb . ' TO ' . $qRole);
$this->query('GRANT USAGE, CREATE ON SCHEMA public TO ' . $qRole, [], $dbName);
$this->query('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ' . $qRole, [], $dbName);
$this->query('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ' . $qRole, [], $dbName);
$this->query('GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ' . $qRole, [], $dbName);
$owner = $this->getDatabaseOwner($dbName);
if ($owner !== null) {
$qOwner = $this->quoteIdentifier($owner);
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO ' . $qRole,
[],
$dbName
);
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO ' . $qRole,
[],
$dbName
);
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO ' . $qRole,
[],
$dbName
);
}
}
private function revokePrivilegesInternal(string $dbName, string $role, bool $removeProfile): void
{
$qDb = $this->quoteIdentifier($dbName);
$qRole = $this->quoteIdentifier($role);
$this->tryQuery('REVOKE ALL PRIVILEGES ON DATABASE ' . $qDb . ' FROM ' . $qRole);
$this->tryQuery('REVOKE ALL PRIVILEGES ON SCHEMA public FROM ' . $qRole, [], $dbName);
$this->tryQuery('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM ' . $qRole, [], $dbName);
$this->tryQuery('REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM ' . $qRole, [], $dbName);
$this->tryQuery('REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public FROM ' . $qRole, [], $dbName);
$owner = $this->getDatabaseOwner($dbName);
if ($owner !== null) {
$qOwner = $this->quoteIdentifier($owner);
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public REVOKE ALL PRIVILEGES ON TABLES FROM ' . $qRole,
[],
$dbName
);
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public REVOKE ALL PRIVILEGES ON SEQUENCES FROM ' . $qRole,
[],
$dbName
);
$this->tryQuery(
'ALTER DEFAULT PRIVILEGES FOR ROLE ' . $qOwner . ' IN SCHEMA public REVOKE ALL PRIVILEGES ON FUNCTIONS FROM ' . $qRole,
[],
$dbName
);
}
if ($removeProfile) {
$this->query('DELETE FROM da_plugin.grant_profiles WHERE db_name = $1 AND role_name = $2', [$dbName, $role]);
}
}
/**
* @param string[] $privileges
*/
private function saveGrantProfile(string $dbName, string $role, array $privileges): void
{
sort($privileges);
$serialized = implode(',', $privileges);
$this->query(
'INSERT INTO da_plugin.grant_profiles (db_name, role_name, privileges, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (db_name, role_name)
DO UPDATE SET privileges = EXCLUDED.privileges, updated_at = now()',
[$dbName, $role, $serialized]
);
}
private function sanitizeHostNote(string $note): string
{
$note = preg_replace('/[\r\n\t]+/', ' ', $note) ?? '';
$note = preg_replace('/\s+/', ' ', $note) ?? '';
$note = trim($note);
if (strlen($note) > NamePolicy::MAX_HOST_COMMENT_LEN) {
$note = substr($note, 0, NamePolicy::MAX_HOST_COMMENT_LEN);
}
return $note;
}
private function likePattern(string $prefix): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $prefix) . '%';
}
private function quoteIdentifier(string $name): string
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) {
throw new RuntimeException('Nieprawidłowy identyfikator PostgreSQL: ' . $name);
}
if (strlen($name) > NamePolicy::MAX_IDENTIFIER_LEN) {
throw new RuntimeException('Identyfikator PostgreSQL przekracza 63 znaki: ' . $name);
}
return '"' . str_replace('"', '""', $name) . '"';
}
private function enforceDatabaseIsolation(string $dbName): void
{
$qDb = $this->quoteIdentifier($dbName);
$this->query('REVOKE ALL PRIVILEGES ON DATABASE ' . $qDb . ' FROM PUBLIC');
$this->tryQuery('REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC', [], $dbName);
}
private function quoteLiteral(string $value, string $database = 'postgres'): string
{
$conn = $this->connect($database);
$quoted = pg_escape_literal($conn, $value);
if ($quoted === false) {
throw new RuntimeException('Nie udało się bezpiecznie zacytować wartości SQL.');
}
return $quoted;
}
/**
* @param array<int,mixed> $params
*/
private function query(string $sql, array $params = [], string $database = 'postgres')
{
$conn = $this->connect($database);
if ($params === []) {
$res = @pg_query($conn, $sql);
} else {
$res = @pg_query_params($conn, $sql, $params);
}
if ($res === false) {
throw new RuntimeException(trim((string)pg_last_error($conn)) . ' | SQL: ' . $sql);
}
return $res;
}
/**
* @param array<int,mixed> $params
*/
private function tryQuery(string $sql, array $params = [], string $database = 'postgres'): void
{
try {
$this->query($sql, $params, $database);
} catch (Throwable $e) {
// celowo ignorujemy błędy pomocnicze (kompatybilność i idempotencja)
}
}
/**
* @param array<int,mixed> $params
* @return array<int,array<string,mixed>>
*/
private function fetchAll(string $sql, array $params = [], string $database = 'postgres'): array
{
$res = $this->query($sql, $params, $database);
$rows = pg_fetch_all($res);
if ($rows === false) {
return [];
}
return $rows;
}
/**
* @param array<int,mixed> $params
* @return array<string,mixed>
*/
private function fetchOne(string $sql, array $params = [], string $database = 'postgres'): array
{
$rows = $this->fetchAll($sql, $params, $database);
if (empty($rows)) {
return [];
}
return $rows[0];
}
/**
* @return resource
*/
private function connect(string $database = 'postgres')
{
if (isset($this->connections[$database])) {
if (@pg_connection_status($this->connections[$database]) === PGSQL_CONNECTION_OK) {
return $this->connections[$database];
}
@pg_close($this->connections[$database]);
unset($this->connections[$database]);
}
$targetDb = $database !== '' ? $database : $this->credentials['database'];
$connString = sprintf(
"host=%s port=%d dbname=%s user=%s password=%s connect_timeout=5",
$this->escapeConnValue($this->credentials['host']),
$this->credentials['port'],
$this->escapeConnValue($targetDb),
$this->escapeConnValue($this->credentials['user']),
$this->escapeConnValue($this->credentials['password'])
);
$conn = @pg_connect($connString);
if ($conn === false) {
throw new RuntimeException('Nie udało się połączyć z PostgreSQL.');
}
$this->connections[$database] = $conn;
return $conn;
}
private function resetConnections(): void
{
foreach ($this->connections as $conn) {
@pg_close($conn);
}
$this->connections = [];
}
private function waitForConnectionAfterRestart(int $timeoutSeconds = 20): bool
{
$deadline = time() + max(1, $timeoutSeconds);
do {
try {
$this->resetConnections();
$this->query('SELECT 1');
return true;
} catch (Throwable $e) {
usleep(500000);
}
} while (time() < $deadline);
$this->resetConnections();
return false;
}
private function escapeConnValue(string $value): string
{
return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'";
}
}