feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
[CmdletBinding(DefaultParameterSetName = 'SqlCredential')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateLength(1, 260)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')]
|
||||
[string]$Database,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'SqlCredential')]
|
||||
[Management.Automation.PSCredential]$Credential,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'WindowsCredential')]
|
||||
[switch]$UseWindowsAuthentication,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
|
||||
[ValidateRange(5, 60)]
|
||||
[int]$ConnectionTimeoutSeconds = 15,
|
||||
|
||||
[ValidateRange(5, 120)]
|
||||
[int]$CommandTimeoutSeconds = 60
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
|
||||
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
|
||||
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
|
||||
throw 'lserp_select_only_catalog_failed:windows_powershell_51_required'
|
||||
}
|
||||
|
||||
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
||||
$maximumCatalogEntries = 100000
|
||||
$maximumOutputBytes = 16 * 1024 * 1024
|
||||
$maximumToolBytes = 2 * 1024 * 1024
|
||||
$toolPath = [string]$PSCommandPath
|
||||
$connection = $null
|
||||
$connectionBuilder = $null
|
||||
$metadataCommand = $null
|
||||
$metadataReader = $null
|
||||
$outputFull = $null
|
||||
$outputCreated = $false
|
||||
$published = $false
|
||||
|
||||
function Throw-CatalogError([string]$Code) {
|
||||
throw ('lserp_select_only_catalog_failed:' + $Code)
|
||||
}
|
||||
|
||||
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
||||
try {
|
||||
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
|
||||
while ($null -ne $current) {
|
||||
if (-not $current.Exists -or
|
||||
(($current.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CatalogError $Code
|
||||
}
|
||||
$current = $current.Parent
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError $Code
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-NewJsonPath([string]$Path) {
|
||||
try {
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetExtension($full) -ine '.json' -or
|
||||
[IO.File]::Exists($full) -or
|
||||
[IO.Directory]::Exists($full)) {
|
||||
Throw-CatalogError 'output_path_invalid'
|
||||
}
|
||||
$directory = [IO.Path]::GetDirectoryName($full)
|
||||
if ([string]::IsNullOrWhiteSpace($directory)) {
|
||||
Throw-CatalogError 'output_path_invalid'
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $directory 'output_path_invalid'
|
||||
return $full
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'output_path_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SafeServer([string]$Value) {
|
||||
if (-not [Text.RegularExpressions.Regex]::IsMatch(
|
||||
$Value,
|
||||
'^[A-Za-z0-9][A-Za-z0-9.-]{0,252}(?:,[1-9][0-9]{0,4})?$')) {
|
||||
Throw-CatalogError 'server_invalid'
|
||||
}
|
||||
$comma = $Value.LastIndexOf(',')
|
||||
if ($comma -ge 0) {
|
||||
$port = 0
|
||||
if (-not [int]::TryParse($Value.Substring($comma + 1), [ref]$port) -or
|
||||
$port -lt 1 -or $port -gt 65535) {
|
||||
Throw-CatalogError 'server_invalid'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SafeText(
|
||||
[string]$Value,
|
||||
[int]$MinimumLength,
|
||||
[int]$MaximumLength,
|
||||
[string]$Code
|
||||
) {
|
||||
if ($null -eq $Value -or
|
||||
$Value.Length -lt $MinimumLength -or
|
||||
$Value.Length -gt $MaximumLength -or
|
||||
-not [string]::Equals(
|
||||
$Value,
|
||||
$Value.Trim(),
|
||||
[StringComparison]::Ordinal)) {
|
||||
Throw-CatalogError $Code
|
||||
}
|
||||
foreach ($character in $Value.ToCharArray()) {
|
||||
if ([char]::IsControl($character)) { Throw-CatalogError $Code }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Sha256Text([string]$Value) {
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = $utf8.GetBytes($Value)
|
||||
return ([BitConverter]::ToString(
|
||||
$algorithm.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$algorithm.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Read-Int64(
|
||||
[Data.Common.DbDataReader]$Reader,
|
||||
[string]$Name,
|
||||
[long]$Minimum,
|
||||
[long]$Maximum
|
||||
) {
|
||||
try {
|
||||
$ordinal = $Reader.GetOrdinal($Name)
|
||||
if ($Reader.IsDBNull($ordinal)) {
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
$value = [Convert]::ToInt64(
|
||||
$Reader.GetValue($ordinal),
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
if ($value -lt $Minimum -or $value -gt $Maximum) {
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
return $value
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Read-Text(
|
||||
[Data.Common.DbDataReader]$Reader,
|
||||
[string]$Name,
|
||||
[int]$MinimumLength,
|
||||
[int]$MaximumLength
|
||||
) {
|
||||
try {
|
||||
$ordinal = $Reader.GetOrdinal($Name)
|
||||
if ($Reader.IsDBNull($ordinal)) {
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
$value = [Convert]::ToString(
|
||||
$Reader.GetValue($ordinal),
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
Assert-SafeText $value $MinimumLength $MaximumLength `
|
||||
'query_contract_invalid'
|
||||
return $value
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'query_contract_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentToolFileSha256([string]$Path) {
|
||||
try {
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
$item = Get-Item -LiteralPath $full -Force
|
||||
if ($item.PSIsContainer -or
|
||||
$item.Length -le 0 -or
|
||||
$item.Length -gt $maximumToolBytes -or
|
||||
(($item.Attributes -band
|
||||
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
Assert-NoReparseDirectoryChain $item.DirectoryName `
|
||||
'tool_source_invalid'
|
||||
$hash = (Get-FileHash -LiteralPath $full `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($hash -cnotmatch '^[0-9a-f]{64}$') {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
return $hash
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-SelectOnlyPermissionGate(
|
||||
[Data.Common.DbConnection]$connection,
|
||||
[string]$permissionQuery,
|
||||
[int]$commandTimeoutSeconds
|
||||
) {
|
||||
$permissionCommand = $null
|
||||
$permissionReader = $null
|
||||
try {
|
||||
$permissionCommand = $connection.CreateCommand()
|
||||
$permissionCommand.CommandType = [Data.CommandType]::Text
|
||||
$permissionCommand.CommandText = $permissionQuery
|
||||
$permissionCommand.CommandTimeout = $commandTimeoutSeconds
|
||||
$permissionReader = $permissionCommand.ExecuteReader(
|
||||
[Data.CommandBehavior]::SingleResult)
|
||||
if (-not $permissionReader.Read()) {
|
||||
Throw-CatalogError 'permission_contract_invalid'
|
||||
}
|
||||
$permissionColumns = @(
|
||||
'database_control', 'database_alter', 'database_insert',
|
||||
'database_update', 'database_delete', 'database_execute',
|
||||
'database_create_table', 'database_create_procedure',
|
||||
'database_create_view', 'database_create_function',
|
||||
'server_control', 'server_alter_login', 'server_impersonate_login',
|
||||
'sysadmin_member', 'db_owner_member', 'db_ddladmin_member',
|
||||
'db_datawriter_member', 'writable_object_count',
|
||||
'executable_object_count', 'writable_schema_count'
|
||||
)
|
||||
foreach ($column in $permissionColumns) {
|
||||
if ((Read-Int64 $permissionReader $column 0 10000000) -ne 0) {
|
||||
Throw-CatalogError 'database_principal_not_select_only'
|
||||
}
|
||||
}
|
||||
if ($permissionReader.Read() -or $permissionReader.NextResult()) {
|
||||
Throw-CatalogError 'permission_contract_invalid'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $permissionReader) { $permissionReader.Dispose() }
|
||||
if ($null -ne $permissionCommand) { $permissionCommand.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
function New-RestrictedFileSecurity {
|
||||
try {
|
||||
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$localSystem = [Security.Principal.SecurityIdentifier]::new(
|
||||
[Security.Principal.WellKnownSidType]::LocalSystemSid,
|
||||
$null)
|
||||
$security = New-Object Security.AccessControl.FileSecurity
|
||||
$security.SetOwner($currentUser)
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$allow = [Security.AccessControl.AccessControlType]::Allow
|
||||
foreach ($identity in @($currentUser, $localSystem)) {
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$identity,
|
||||
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||
$allow)
|
||||
[void]$security.AddAccessRule($rule)
|
||||
}
|
||||
return $security
|
||||
}
|
||||
catch {
|
||||
Throw-CatalogError 'output_acl_invalid'
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-RestrictedJson([string]$Path, [string]$Json) {
|
||||
$stream = $null
|
||||
$writer = $null
|
||||
try {
|
||||
$bytes = $utf8.GetByteCount($Json)
|
||||
if ($bytes -le 0 -or $bytes -gt $maximumOutputBytes) {
|
||||
Throw-CatalogError 'output_size_invalid'
|
||||
}
|
||||
$security = New-RestrictedFileSecurity
|
||||
$stream = [IO.FileStream]::new(
|
||||
$Path,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[Security.AccessControl.FileSystemRights]::Write,
|
||||
[IO.FileShare]::None,
|
||||
4096,
|
||||
[IO.FileOptions]::WriteThrough,
|
||||
$security)
|
||||
$script:outputCreated = $true
|
||||
$writer = [IO.StreamWriter]::new($stream, $utf8, 4096, $false)
|
||||
$stream = $null
|
||||
$writer.Write($Json)
|
||||
$writer.Flush()
|
||||
$writer.Dispose()
|
||||
$writer = $null
|
||||
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.Length -le 0 -or $item.Length -gt $maximumOutputBytes -or
|
||||
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
||||
Throw-CatalogError 'output_publish_invalid'
|
||||
}
|
||||
$acl = [IO.File]::GetAccessControl($Path)
|
||||
if (-not $acl.AreAccessRulesProtected) {
|
||||
Throw-CatalogError 'output_acl_invalid'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $writer) { $writer.Dispose() }
|
||||
if ($null -ne $stream) { $stream.Dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
$permissionQuery = @'
|
||||
SELECT
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CONTROL'), 0)) AS database_control,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER'), 0)) AS database_alter,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'INSERT'), 0)) AS database_insert,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'UPDATE'), 0)) AS database_update,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'DELETE'), 0)) AS database_delete,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'EXECUTE'), 0)) AS database_execute,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE TABLE'), 0)) AS database_create_table,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE PROCEDURE'), 0)) AS database_create_procedure,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE VIEW'), 0)) AS database_create_view,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CREATE FUNCTION'), 0)) AS database_create_function,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'CONTROL SERVER'), 0)) AS server_control,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'ALTER ANY LOGIN'), 0)) AS server_alter_login,
|
||||
CONVERT(int, ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'IMPERSONATE ANY LOGIN'), 0)) AS server_impersonate_login,
|
||||
CONVERT(int, ISNULL(IS_SRVROLEMEMBER('sysadmin'), 0)) AS sysadmin_member,
|
||||
CONVERT(int, ISNULL(IS_MEMBER('db_owner'), 0)) AS db_owner_member,
|
||||
CONVERT(int, ISNULL(IS_MEMBER('db_ddladmin'), 0)) AS db_ddladmin_member,
|
||||
CONVERT(int, ISNULL(IS_MEMBER('db_datawriter'), 0)) AS db_datawriter_member,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.objects AS writable_object
|
||||
WHERE writable_object.is_ms_shipped = 0
|
||||
AND writable_object.type IN ('U', 'V')
|
||||
AND
|
||||
(
|
||||
ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'INSERT'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'UPDATE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'DELETE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'ALTER'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(writable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(writable_object.name), 'OBJECT', 'CONTROL'), 0) = 1
|
||||
)
|
||||
)) AS writable_object_count,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.objects AS executable_object
|
||||
WHERE executable_object.is_ms_shipped = 0
|
||||
AND executable_object.type IN ('P', 'PC', 'FN', 'IF', 'TF')
|
||||
AND
|
||||
(
|
||||
ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(executable_object.name), 'OBJECT', 'EXECUTE'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(executable_object.name), 'OBJECT', 'ALTER'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(SCHEMA_NAME(executable_object.schema_id)) + N'.' +
|
||||
QUOTENAME(executable_object.name), 'OBJECT', 'CONTROL'), 0) = 1
|
||||
)
|
||||
)) AS executable_object_count,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.schemas AS writable_schema
|
||||
WHERE writable_schema.name NOT IN (N'sys', N'INFORMATION_SCHEMA')
|
||||
AND
|
||||
(
|
||||
ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(writable_schema.name), 'SCHEMA', 'ALTER'), 0) = 1
|
||||
OR ISNULL(HAS_PERMS_BY_NAME(
|
||||
QUOTENAME(writable_schema.name), 'SCHEMA', 'CONTROL'), 0) = 1
|
||||
)
|
||||
)) AS writable_schema_count;
|
||||
'@
|
||||
|
||||
$metadataQuery = @'
|
||||
SELECT
|
||||
CONVERT(nvarchar(128), DB_NAME()) AS database_name,
|
||||
CONVERT(nvarchar(128), SERVERPROPERTY('ServerName')) AS actual_server_name,
|
||||
CONVERT(nvarchar(128), SUSER_SNAME()) AS effective_principal,
|
||||
CONVERT(int, SERVERPROPERTY('ProductMajorVersion')) AS sql_server_major_version,
|
||||
CONVERT(int, current_database.compatibility_level) AS compatibility_level,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.tables WHERE is_ms_shipped = 0)) AS user_table_count,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.views WHERE is_ms_shipped = 0)) AS user_view_count,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.procedures WHERE is_ms_shipped = 0)) AS user_procedure_count,
|
||||
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.triggers WHERE is_ms_shipped = 0)) AS user_trigger_count,
|
||||
CONVERT(bigint,
|
||||
(
|
||||
SELECT COUNT_BIG(1)
|
||||
FROM sys.objects
|
||||
WHERE schema_id = SCHEMA_ID(N'dbo')
|
||||
AND
|
||||
(
|
||||
(type = 'U' AND name IN
|
||||
(
|
||||
N'p_agent_business_audit',
|
||||
N'p_agent_business_source_document',
|
||||
N'p_agent_command_idempotency',
|
||||
N'p_agent_integration_outbox',
|
||||
N'p_agent_purchase_currency_crosswalk',
|
||||
N'p_agent_purchase_row_scope',
|
||||
N'p_agent_workflow_adapter_evidence',
|
||||
N'p_agent_workflow_adapter_evidence_v2'
|
||||
))
|
||||
OR
|
||||
(type = 'P' AND name IN
|
||||
(
|
||||
N'p_lserp_agent_workflow_read',
|
||||
N'p_lserp_agent_workflow_read_compat100',
|
||||
N'p_lserp_agent_workflow_readiness',
|
||||
N'p_lserp_agent_workflow_readiness_v2',
|
||||
N'p_lserp_agent_workflow_readiness_v3',
|
||||
N'p_lserp_agent_workflow_write',
|
||||
N'p_lserp_agent_workflow_write_leave_compat100',
|
||||
N'p_lserp_agent_workflow_write_purchase_compat100'
|
||||
))
|
||||
)
|
||||
)) AS agent_workflow_object_count
|
||||
FROM sys.databases AS current_database
|
||||
WHERE current_database.database_id = DB_ID();
|
||||
|
||||
SELECT TOP (100001)
|
||||
catalog_entry.entry_kind,
|
||||
catalog_entry.schema_name,
|
||||
catalog_entry.object_name,
|
||||
catalog_entry.object_kind,
|
||||
catalog_entry.member_name
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
CONVERT(varchar(16), 'object') AS entry_kind,
|
||||
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)) AS schema_name,
|
||||
CONVERT(nvarchar(128), catalog_object.name) AS object_name,
|
||||
CONVERT(varchar(16), CASE catalog_object.type
|
||||
WHEN 'U' THEN 'table'
|
||||
WHEN 'V' THEN 'view'
|
||||
WHEN 'P' THEN 'procedure'
|
||||
ELSE 'invalid' END) AS object_kind,
|
||||
CONVERT(nvarchar(128), N'') AS member_name
|
||||
FROM sys.objects AS catalog_object
|
||||
WHERE catalog_object.is_ms_shipped = 0
|
||||
AND catalog_object.type IN ('U', 'V', 'P')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONVERT(varchar(16), 'column'),
|
||||
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)),
|
||||
CONVERT(nvarchar(128), catalog_object.name),
|
||||
CONVERT(varchar(16), CASE catalog_object.type
|
||||
WHEN 'U' THEN 'table'
|
||||
WHEN 'V' THEN 'view'
|
||||
ELSE 'invalid' END),
|
||||
CONVERT(nvarchar(128), catalog_column.name)
|
||||
FROM sys.objects AS catalog_object
|
||||
INNER JOIN sys.columns AS catalog_column
|
||||
ON catalog_column.object_id = catalog_object.object_id
|
||||
WHERE catalog_object.is_ms_shipped = 0
|
||||
AND catalog_object.type IN ('U', 'V')
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONVERT(varchar(16), 'parameter'),
|
||||
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_procedure.schema_id)),
|
||||
CONVERT(nvarchar(128), catalog_procedure.name),
|
||||
CONVERT(varchar(16), 'procedure'),
|
||||
CONVERT(nvarchar(128), catalog_parameter.name)
|
||||
FROM sys.procedures AS catalog_procedure
|
||||
INNER JOIN sys.parameters AS catalog_parameter
|
||||
ON catalog_parameter.object_id = catalog_procedure.object_id
|
||||
AND catalog_parameter.parameter_id > 0
|
||||
WHERE catalog_procedure.is_ms_shipped = 0
|
||||
) AS catalog_entry
|
||||
ORDER BY
|
||||
catalog_entry.object_kind,
|
||||
catalog_entry.schema_name,
|
||||
catalog_entry.object_name,
|
||||
catalog_entry.entry_kind,
|
||||
catalog_entry.member_name;
|
||||
'@
|
||||
|
||||
try {
|
||||
Assert-SafeServer $Server
|
||||
$outputFull = Resolve-NewJsonPath $OutputPath
|
||||
$executedScriptText = $null
|
||||
try {
|
||||
$executedScriptText = [string]
|
||||
$MyInvocation.MyCommand.ScriptBlock.Ast.Extent.Text
|
||||
}
|
||||
catch {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($executedScriptText) -or
|
||||
$utf8.GetByteCount($executedScriptText) -gt $maximumToolBytes) {
|
||||
Throw-CatalogError 'tool_source_invalid'
|
||||
}
|
||||
$executedToolSha256 = Get-Sha256Text $executedScriptText
|
||||
$initialToolFileSha256 = Get-CurrentToolFileSha256 $toolPath
|
||||
if ($initialToolFileSha256 -cne $executedToolSha256) {
|
||||
Throw-CatalogError 'tool_source_changed'
|
||||
}
|
||||
if ($null -ne $Credential) {
|
||||
Assert-SafeText $Credential.UserName 1 128 'credential_user_invalid'
|
||||
}
|
||||
|
||||
$connectionBuilder = [System.Data.SqlClient.SqlConnectionStringBuilder]::new()
|
||||
$connectionBuilder.DataSource = $Server
|
||||
$connectionBuilder.InitialCatalog = $Database
|
||||
$connectionBuilder.ConnectTimeout = $ConnectionTimeoutSeconds
|
||||
$connectionBuilder.Encrypt = $true
|
||||
$connectionBuilder.TrustServerCertificate = $false
|
||||
$connectionBuilder.ApplicationIntent =
|
||||
[System.Data.SqlClient.ApplicationIntent]::ReadOnly
|
||||
$connectionBuilder.PersistSecurityInfo = $false
|
||||
$connectionBuilder.Pooling = $false
|
||||
$connectionBuilder.MultipleActiveResultSets = $false
|
||||
$connectionBuilder.ApplicationName =
|
||||
'Langsu Lserp SELECT-only catalog snapshot'
|
||||
if ($PSCmdlet.ParameterSetName -eq 'WindowsCredential') {
|
||||
$connectionBuilder.IntegratedSecurity = $true
|
||||
}
|
||||
else {
|
||||
$connectionBuilder.IntegratedSecurity = $false
|
||||
$connectionBuilder.UserID = $Credential.UserName
|
||||
$connectionBuilder.Password = $Credential.GetNetworkCredential().Password
|
||||
}
|
||||
|
||||
$connection = [System.Data.SqlClient.SqlConnection]::new(
|
||||
$connectionBuilder.ConnectionString)
|
||||
$connection.Open()
|
||||
|
||||
Assert-SelectOnlyPermissionGate `
|
||||
$connection $permissionQuery $CommandTimeoutSeconds
|
||||
|
||||
$metadataCommand = $connection.CreateCommand()
|
||||
$metadataCommand.CommandType = [Data.CommandType]::Text
|
||||
$metadataCommand.CommandText = $metadataQuery
|
||||
$metadataCommand.CommandTimeout = $CommandTimeoutSeconds
|
||||
$metadataReader = $metadataCommand.ExecuteReader(
|
||||
[Data.CommandBehavior]::SequentialAccess)
|
||||
if (-not $metadataReader.Read()) {
|
||||
Throw-CatalogError 'metadata_contract_invalid'
|
||||
}
|
||||
$actualDatabase = Read-Text $metadataReader 'database_name' 1 128
|
||||
$actualServer = Read-Text $metadataReader 'actual_server_name' 1 128
|
||||
$effectivePrincipal = Read-Text $metadataReader 'effective_principal' 1 128
|
||||
if (-not [string]::Equals(
|
||||
$actualDatabase,
|
||||
$Database,
|
||||
[StringComparison]::OrdinalIgnoreCase)) {
|
||||
Throw-CatalogError 'database_identity_mismatch'
|
||||
}
|
||||
$sqlServerMajorVersion = Read-Int64 `
|
||||
$metadataReader 'sql_server_major_version' 9 99
|
||||
$compatibilityLevel = Read-Int64 `
|
||||
$metadataReader 'compatibility_level' 80 200
|
||||
$userTableCount = Read-Int64 `
|
||||
$metadataReader 'user_table_count' 0 10000000
|
||||
$userViewCount = Read-Int64 `
|
||||
$metadataReader 'user_view_count' 0 10000000
|
||||
$userProcedureCount = Read-Int64 `
|
||||
$metadataReader 'user_procedure_count' 0 10000000
|
||||
$userTriggerCount = Read-Int64 `
|
||||
$metadataReader 'user_trigger_count' 0 10000000
|
||||
$agentWorkflowObjectCount = Read-Int64 `
|
||||
$metadataReader 'agent_workflow_object_count' 0 16
|
||||
if ($metadataReader.Read() -or -not $metadataReader.NextResult()) {
|
||||
Throw-CatalogError 'metadata_contract_invalid'
|
||||
}
|
||||
|
||||
$catalogHashSet = [Collections.Generic.HashSet[string]]::new(
|
||||
[StringComparer]::Ordinal)
|
||||
while ($metadataReader.Read()) {
|
||||
if ($catalogHashSet.Count -ge $maximumCatalogEntries) {
|
||||
Throw-CatalogError 'catalog_too_large'
|
||||
}
|
||||
$entryKind = Read-Text $metadataReader 'entry_kind' 1 16
|
||||
$schemaName = Read-Text $metadataReader 'schema_name' 1 128
|
||||
$objectName = Read-Text $metadataReader 'object_name' 1 128
|
||||
$objectKind = Read-Text $metadataReader 'object_kind' 1 16
|
||||
$memberMinimum = if ($entryKind -eq 'object') { 0 } else { 1 }
|
||||
$memberName = Read-Text `
|
||||
$metadataReader 'member_name' $memberMinimum 128
|
||||
if ($entryKind -cnotin @('object', 'column', 'parameter') -or
|
||||
$objectKind -cnotin @('table', 'view', 'procedure') -or
|
||||
($entryKind -eq 'object' -and $memberName.Length -ne 0) -or
|
||||
($entryKind -eq 'column' -and $objectKind -eq 'procedure') -or
|
||||
($entryKind -eq 'parameter' -and $objectKind -ne 'procedure')) {
|
||||
Throw-CatalogError 'catalog_contract_invalid'
|
||||
}
|
||||
$catalogKey = [string]::Join(
|
||||
[char]0x1f,
|
||||
@(
|
||||
$entryKind,
|
||||
$schemaName.ToLowerInvariant(),
|
||||
$objectName.ToLowerInvariant(),
|
||||
$objectKind,
|
||||
$memberName.ToLowerInvariant()
|
||||
))
|
||||
$entryHash = Get-Sha256Text $catalogKey
|
||||
if (-not $catalogHashSet.Add($entryHash)) {
|
||||
Throw-CatalogError 'catalog_contract_invalid'
|
||||
}
|
||||
}
|
||||
if ($catalogHashSet.Count -le 0 -or $metadataReader.NextResult()) {
|
||||
Throw-CatalogError 'metadata_contract_invalid'
|
||||
}
|
||||
$metadataReader.Dispose()
|
||||
$metadataReader = $null
|
||||
|
||||
Assert-SelectOnlyPermissionGate `
|
||||
$connection $permissionQuery $CommandTimeoutSeconds
|
||||
$finalToolFileSha256 = Get-CurrentToolFileSha256 $toolPath
|
||||
if ($finalToolFileSha256 -cne $initialToolFileSha256 -or
|
||||
$finalToolFileSha256 -cne $executedToolSha256) {
|
||||
Throw-CatalogError 'tool_source_changed'
|
||||
}
|
||||
|
||||
[string[]]$catalogHashes = @($catalogHashSet)
|
||||
[Array]::Sort($catalogHashes, [StringComparer]::Ordinal)
|
||||
$catalogSetSha256 = Get-Sha256Text(
|
||||
[string]::Join("`n", $catalogHashes))
|
||||
$toolSha256 = $executedToolSha256
|
||||
$databaseScopeFingerprint = Get-Sha256Text([string]::Join(
|
||||
"`n",
|
||||
@(
|
||||
'sqlserver-select-only-catalog-v1',
|
||||
$Server.ToLowerInvariant(),
|
||||
$actualServer.ToLowerInvariant(),
|
||||
$actualDatabase.ToLowerInvariant(),
|
||||
$effectivePrincipal.ToLowerInvariant()
|
||||
)))
|
||||
$snapshot = [ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
snapshotType = 'select_only_sqlserver_catalog_hashes'
|
||||
generatedAtUtc = [DateTime]::UtcNow.ToString(
|
||||
'yyyy-MM-ddTHH:mm:ss.fffffffZ',
|
||||
[Globalization.CultureInfo]::InvariantCulture)
|
||||
toolSha256 = $toolSha256
|
||||
databaseScopeFingerprint = $databaseScopeFingerprint
|
||||
identity = [ordered]@{
|
||||
requestedServerSha256 = Get-Sha256Text ($Server.ToLowerInvariant())
|
||||
actualServerSha256 = Get-Sha256Text ($actualServer.ToLowerInvariant())
|
||||
databaseNameSha256 = Get-Sha256Text ($actualDatabase.ToLowerInvariant())
|
||||
effectivePrincipalSha256 = Get-Sha256Text `
|
||||
($effectivePrincipal.ToLowerInvariant())
|
||||
}
|
||||
database = [ordered]@{
|
||||
sqlServerMajorVersion = $sqlServerMajorVersion
|
||||
compatibilityLevel = $compatibilityLevel
|
||||
userTableCount = $userTableCount
|
||||
userViewCount = $userViewCount
|
||||
userProcedureCount = $userProcedureCount
|
||||
userTriggerCount = $userTriggerCount
|
||||
agentWorkflowObjectCount = $agentWorkflowObjectCount
|
||||
agentWorkflowObjectsPresent = ($agentWorkflowObjectCount -eq 16)
|
||||
}
|
||||
permissionGate = [ordered]@{
|
||||
passed = $true
|
||||
checkedBeforeCatalogRead = $true
|
||||
checkedAfterCatalogRead = $true
|
||||
databaseWritePermissionCount = 0
|
||||
serverWritePermissionCount = 0
|
||||
writableObjectCount = 0
|
||||
executableObjectCount = 0
|
||||
writableSchemaCount = 0
|
||||
}
|
||||
catalog = [ordered]@{
|
||||
canonicalization = 'lower_invariant_unit_separator_v1'
|
||||
entryCount = $catalogHashes.Count
|
||||
setSha256 = $catalogSetSha256
|
||||
entrySha256 = $catalogHashes
|
||||
}
|
||||
safety = [ordered]@{
|
||||
connectionEncrypted = $true
|
||||
serverCertificateValidated = $true
|
||||
applicationIntent = 'ReadOnly'
|
||||
effectivePrincipalSelectOnly = $true
|
||||
toolSourceBytesStable = $true
|
||||
businessRowsRead = $false
|
||||
storedProceduresExecuted = $false
|
||||
writesAttempted = $false
|
||||
}
|
||||
}
|
||||
$json = ConvertTo-Json -InputObject $snapshot -Depth 8 -Compress
|
||||
Publish-RestrictedJson $outputFull $json
|
||||
$published = $true
|
||||
$snapshotSha256 = (Get-FileHash -LiteralPath $outputFull `
|
||||
-Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
[pscustomobject][ordered]@{
|
||||
schemaVersion = '1.1'
|
||||
passed = $true
|
||||
code = 'ok'
|
||||
outputPath = $outputFull
|
||||
snapshotSha256 = $snapshotSha256
|
||||
databaseScopeFingerprint = $databaseScopeFingerprint
|
||||
catalogEntryCount = $catalogHashes.Count
|
||||
catalogSetSha256 = $catalogSetSha256
|
||||
}
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message.StartsWith(
|
||||
'lserp_select_only_catalog_failed:')) { throw }
|
||||
Throw-CatalogError 'catalog_snapshot_failed'
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $metadataReader) { $metadataReader.Dispose() }
|
||||
if ($null -ne $metadataCommand) { $metadataCommand.Dispose() }
|
||||
if ($null -ne $connection) {
|
||||
try { $connection.Close() } catch { }
|
||||
$connection.Dispose()
|
||||
}
|
||||
if ($null -ne $connectionBuilder) {
|
||||
$connectionBuilder.Password = [string]::Empty
|
||||
$connectionBuilder.UserID = [string]::Empty
|
||||
}
|
||||
if ($outputCreated -and -not $published -and
|
||||
$null -ne $outputFull -and [IO.File]::Exists($outputFull)) {
|
||||
try { [IO.File]::Delete($outputFull) } catch { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user