PSA/server/migrations/20251130120002_create_software_inventory_tables.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

186 lines
8.0 KiB
JavaScript

/**
* Migration: Create Normalized Software Inventory Tables
*
* Replaces the JSONB installed_software columns with normalized tables for:
* - Better querying ("find all assets with Chrome installed")
* - Deduplication (same software across assets shares one catalog entry)
* - Change tracking (detect installs/uninstalls between syncs)
* - Category support (Browser, Security, Productivity, etc.)
* - Future features (license tracking, vulnerability matching)
*
* Tables created:
* - software_catalog: Canonical list of software per tenant
* - asset_software: Junction table linking assets to installed software
*
* Also creates:
* - v_asset_software_details: Helper view for easy querying
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// ============================================================================
// SOFTWARE CATALOG: Canonical list of software (deduplicated per tenant)
// ============================================================================
await knex.schema.createTable('software_catalog', table => {
table.uuid('software_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('tenant').notNullable().references('tenant').inTable('tenants').onDelete('CASCADE');
// Identification
table.string('name', 500).notNullable(); // Software name (e.g., "Google Chrome")
table.string('publisher', 255).nullable(); // Publisher (e.g., "Google LLC")
table.string('normalized_name', 500).notNullable(); // Lowercase, trimmed for matching
// Classification
table.string('category', 100).nullable(); // e.g., "Browser", "Productivity", "Security", "Development"
table.string('software_type', 50).notNullable().defaultTo('application'); // 'application', 'driver', 'update', 'system'
// Management flags
table.boolean('is_managed').notNullable().defaultTo(false); // Tracked for patching/licensing
table.boolean('is_security_relevant').notNullable().defaultTo(false); // Antivirus, firewall, etc.
// Metadata
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
// Primary key
table.primary(['tenant', 'software_id']);
// Ensure unique software per tenant (by normalized name + publisher)
// Using COALESCE to handle null publisher in unique constraint
table.unique(['tenant', 'normalized_name', 'publisher'], {
indexName: 'idx_software_catalog_unique_name_publisher'
});
});
// Indexes for software_catalog
await knex.schema.alterTable('software_catalog', table => {
table.index(['tenant'], 'idx_software_catalog_tenant');
table.index(['tenant', 'normalized_name'], 'idx_software_catalog_name');
table.index(['tenant', 'publisher'], 'idx_software_catalog_publisher');
table.index(['tenant', 'category'], 'idx_software_catalog_category');
table.index(['tenant', 'is_managed'], 'idx_software_catalog_managed');
table.index(['tenant', 'is_security_relevant'], 'idx_software_catalog_security');
});
// ============================================================================
// ASSET SOFTWARE: Junction table linking assets to installed software
// ============================================================================
await knex.schema.createTable('asset_software', table => {
table.uuid('tenant').notNullable();
table.uuid('asset_id').notNullable();
table.uuid('software_id').notNullable();
// Installation details
table.string('version', 100).nullable(); // Installed version
table.date('install_date').nullable(); // When it was installed (from RMM)
table.text('install_path').nullable(); // Installation location
table.bigInteger('size_bytes').nullable(); // Size on disk
// Sync tracking
table.timestamp('first_seen_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); // When we first detected it
table.timestamp('last_seen_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); // Updated each sync
// Status
table.boolean('is_current').notNullable().defaultTo(true); // FALSE = was uninstalled (soft delete)
table.timestamp('uninstalled_at', { useTz: true }).nullable(); // When we detected removal
// Composite primary key
table.primary(['tenant', 'asset_id', 'software_id']);
// Foreign key to assets
table.foreign(['tenant', 'asset_id'])
.references(['tenant', 'asset_id'])
.inTable('assets')
.onDelete('CASCADE');
// Foreign key to software_catalog
table.foreign(['tenant', 'software_id'])
.references(['tenant', 'software_id'])
.inTable('software_catalog')
.onDelete('CASCADE');
});
// Indexes for asset_software - optimized for common query patterns
await knex.schema.alterTable('asset_software', table => {
// 1. "Show all software on asset X" (asset detail page)
table.index(['tenant', 'asset_id', 'is_current'], 'idx_asset_software_asset_current');
// 2. "Find all assets with software Y installed" (fleet search)
table.index(['tenant', 'software_id', 'is_current'], 'idx_asset_software_software_current');
// 3. "Show recently installed software" (audit/reporting)
table.index(['tenant', 'first_seen_at'], 'idx_asset_software_first_seen');
// 4. "Show recently uninstalled software" (change tracking)
table.index(['tenant', 'uninstalled_at'], 'idx_asset_software_uninstalled');
// 5. "Find software by version" (vulnerability/compliance)
table.index(['tenant', 'software_id', 'version'], 'idx_asset_software_version');
});
// ============================================================================
// HELPER VIEW: Denormalized view for easy querying
// ============================================================================
await knex.raw(`
CREATE VIEW v_asset_software_details AS
SELECT
asw.tenant,
asw.asset_id,
a.name AS asset_name,
a.asset_type,
a.client_id,
c.client_name,
sc.software_id,
sc.name AS software_name,
sc.publisher,
sc.normalized_name,
sc.category,
sc.software_type,
sc.is_managed,
sc.is_security_relevant,
asw.version,
asw.install_date,
asw.install_path,
asw.size_bytes,
asw.first_seen_at,
asw.last_seen_at,
asw.is_current,
asw.uninstalled_at
FROM asset_software asw
JOIN software_catalog sc ON sc.tenant = asw.tenant AND sc.software_id = asw.software_id
JOIN assets a ON a.tenant = asw.tenant AND a.asset_id = asw.asset_id
LEFT JOIN clients c ON c.tenant = a.tenant AND c.client_id = a.client_id;
`);
// ============================================================================
// TRIGGERS: Updated timestamp
// ============================================================================
await knex.raw(`
CREATE TRIGGER set_timestamp_software_catalog
BEFORE UPDATE ON software_catalog
FOR EACH ROW
EXECUTE PROCEDURE on_update_timestamp();
`);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// Drop trigger
await knex.raw(`
DROP TRIGGER IF EXISTS set_timestamp_software_catalog ON software_catalog;
`);
// Drop view
await knex.raw(`
DROP VIEW IF EXISTS v_asset_software_details;
`);
// Drop tables in reverse order (junction table first due to FK constraints)
await knex.schema.dropTableIfExists('asset_software');
await knex.schema.dropTableIfExists('software_catalog');
};