*/ 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 */ 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 */ 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> */ 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> */ 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 */ 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 $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 $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 $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 $params * @return array> */ 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 $params * @return array */ 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) . "'"; } }