1066 lines
36 KiB
PHP
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) . "'";
|
|
}
|
|
}
|