diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb967454..cdf970ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,7 @@ jobs: AI_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test AI_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test IDENTITY_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test + INTEGRATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test PLANNING_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test HABIT_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test NOTIFICATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test @@ -208,6 +209,7 @@ jobs: AI_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test AI_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test IDENTITY_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test + INTEGRATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test PLANNING_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test HABIT_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test NOTIFICATION_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_test diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3f85d9..d04c536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to LifeOS are documented in this file. ### Added +- Durable PostgreSQL plugin-installation authority with opaque UUIDv4 installation/workspace/installer identity, exact manifest digests, normalized explicit grants, bounded conflict replay, and atomic revocation evidence in the service-owned `plugin_integration` schema. - A durable PostgreSQL data-rights request ledger with workspace-scoped idempotency, immutable request and terminal receipt digests, one-way completion state, and real integration evidence that erasure receipts survive removal of the source workspace and user. - Migration `0006_data_rights_request_ledger.sql` for the service-owned identity ledger, retaining only bounded opaque authority references and digest/status/timestamp evidence rather than exported personal payloads. - An hourly and manually dispatchable NVIDIA NIM live-conformance harness that pins contextual-orchestrator to an exact reviewed commit, compares strong single-route reasoning with bounded conducted workflows, and retains only validated credential-free quality, safety, orchestration, usage, and ablation evidence. @@ -36,6 +37,7 @@ All notable changes to LifeOS are documented in this file. ### Security +- Plugin installation lookup, conflict replay, and revocation now carry authenticated workspace and installing-user authority through the PostgreSQL boundary; the durable record contains no plaintext plugin secret, token, credential, or password material. - The data-rights request ledger keeps personal export payloads out of durable audit rows and normalizes primary-key/idempotency collisions before dependency errors can escape the service boundary. - The commercial-development model account no longer performs Docker commands, never receives Docker-socket authority, and cannot trigger provider-wide model discovery through the credential bridge. - The scheduled live-model harness uses only `NVIDIA_NIM_API_KEY`, seeds it through the encrypted contextual-orchestrator credential registry, installs hash-locked dependencies from an exact commit, confines LifeOS traffic to loopback, allowlists NVIDIA NIM egress, and excludes provider credentials, prompts, responses, traces, and hidden reasoning from retained artifacts. diff --git a/apps/integration-service/migrations/0001_plugin_installation_record.sql b/apps/integration-service/migrations/0001_plugin_installation_record.sql new file mode 100644 index 00000000..c8c7015c --- /dev/null +++ b/apps/integration-service/migrations/0001_plugin_installation_record.sql @@ -0,0 +1,76 @@ +CREATE SCHEMA IF NOT EXISTS plugin_integration; + +CREATE FUNCTION plugin_integration.capability_array_is_valid(capability_values text[]) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +STRICT +PARALLEL SAFE +AS $$ + SELECT + COALESCE( + bool_and(char_length(capability_name) BETWEEN 1 AND 256), + true + ) + AND cardinality(capability_values) = ( + SELECT count(DISTINCT capability_name COLLATE "C") + FROM unnest(capability_values) AS capability_name + ) + AND capability_values = ARRAY( + SELECT capability_name + FROM unnest(capability_values) AS capability_name + ORDER BY capability_name COLLATE "C" + ); +$$; + +CREATE TABLE plugin_integration.plugin_installation_record ( + installation_id uuid PRIMARY KEY, + workspace_id uuid NOT NULL, + installed_by_user_id uuid NOT NULL, + plugin_id text NOT NULL, + plugin_contract_version text NOT NULL, + manifest_sha256 text NOT NULL, + granted_capabilities text[] NOT NULL, + installation_status text NOT NULL DEFAULT 'active', + installed_at timestamptz NOT NULL, + revoked_at timestamptz, + CONSTRAINT plugin_installation_id_uuid_v4 CHECK ( + installation_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT plugin_installation_workspace_id_uuid_v4 CHECK ( + workspace_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT plugin_installation_user_id_uuid_v4 CHECK ( + installed_by_user_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT plugin_installation_plugin_id_length CHECK ( + char_length(plugin_id) BETWEEN 1 AND 256 + ), + CONSTRAINT plugin_installation_contract_version_length CHECK ( + char_length(plugin_contract_version) BETWEEN 1 AND 128 + ), + CONSTRAINT plugin_installation_manifest_sha256 CHECK ( + char_length(manifest_sha256) = 64 + AND manifest_sha256 ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT plugin_installation_capability_count CHECK ( + cardinality(granted_capabilities) BETWEEN 0 AND 32 + ), + CONSTRAINT plugin_installation_capability_array CHECK ( + array_position(granted_capabilities, NULL) IS NULL + AND plugin_integration.capability_array_is_valid(granted_capabilities) + ), + CONSTRAINT plugin_installation_status_valid CHECK ( + installation_status IN ('active', 'revoked') + ), + CONSTRAINT plugin_installation_lifecycle_consistency CHECK ( + (revoked_at IS NULL OR revoked_at >= installed_at) + AND ( + (installation_status = 'active' AND revoked_at IS NULL) + OR (installation_status = 'revoked' AND revoked_at IS NOT NULL) + ) + ) +); + +CREATE INDEX plugin_installation_workspace_index + ON plugin_integration.plugin_installation_record (workspace_id, installation_status); diff --git a/apps/integration-service/src/plugin-installation-migration.test.ts b/apps/integration-service/src/plugin-installation-migration.test.ts new file mode 100644 index 00000000..923f90c1 --- /dev/null +++ b/apps/integration-service/src/plugin-installation-migration.test.ts @@ -0,0 +1,266 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it } from 'vitest'; + +const MIGRATION_PATH = join( + __dirname, + '..', + 'migrations', + '0001_plugin_installation_record.sql', +); +const MIGRATION_SQL = readFileSync(MIGRATION_PATH, 'utf8'); +const DATABASE_URL = process.env.INTEGRATION_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; + +interface SqlExecution { + readonly status: number | null; + readonly stdout: string; + readonly stderr: string; +} + +/** Executes one isolated PostgreSQL client process against the dedicated disposable integration database. */ +function executeSql(sql: string): SqlExecution { + if (!DATABASE_URL) { + throw new Error('A dedicated PostgreSQL integration test database URL is required'); + } + const target = new URL(DATABASE_URL); + const result = spawnSync( + 'psql', + [ + '-X', + '-v', + 'ON_ERROR_STOP=1', + '-h', + target.hostname, + '-p', + target.port || '5432', + '-U', + decodeURIComponent(target.username), + '-d', + decodeURIComponent(target.pathname.replace(/^\//u, '')), + '-Atq', + ], + { + input: sql, + encoding: 'utf8', + env: { + ...process.env, + PGPASSWORD: decodeURIComponent(target.password), + }, + }, + ); + if (result.error) { + throw result.error; + } + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +/** Applies SQL and surfaces only bounded diagnostics when an expected setup fails. */ +function requireSqlSuccess(sql: string): string { + const result = executeSql(sql); + if (result.status !== 0) { + throw new Error(`PostgreSQL test setup failed: ${result.stderr.slice(0, 500)}`); + } + return result.stdout.trim(); +} + +/** Proves that a specific PostgreSQL constraint rejects one fixed hostile fixture. */ +function expectSqlFailure(sql: string, expectedConstraint: string): void { + const result = executeSql(sql); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(expectedConstraint); +} + +describe('plugin installation migration contract', () => { + it('uses descriptive multiword database names and stores authority evidence without secret material', () => { + expect(MIGRATION_SQL).toContain( + 'CREATE SCHEMA IF NOT EXISTS plugin_integration', + ); + expect(MIGRATION_SQL).toContain( + 'CREATE TABLE plugin_integration.plugin_installation_record', + ); + for (const column of [ + 'installation_id uuid PRIMARY KEY', + 'workspace_id uuid NOT NULL', + 'installed_by_user_id uuid NOT NULL', + 'plugin_id text NOT NULL', + 'plugin_contract_version text NOT NULL', + 'manifest_sha256 text NOT NULL', + 'granted_capabilities text[] NOT NULL', + "installation_status text NOT NULL DEFAULT 'active'", + 'installed_at timestamptz NOT NULL', + 'revoked_at timestamptz', + ]) { + expect(MIGRATION_SQL).toContain(column); + } + for (const constraint of [ + 'plugin_installation_id_uuid_v4', + 'plugin_installation_workspace_id_uuid_v4', + 'plugin_installation_user_id_uuid_v4', + 'plugin_installation_manifest_sha256', + 'plugin_installation_capability_count', + 'plugin_installation_capability_array', + 'plugin_installation_lifecycle_consistency', + ]) { + expect(MIGRATION_SQL).toContain(`CONSTRAINT ${constraint}`); + } + expect(MIGRATION_SQL).toContain( + 'plugin_integration.capability_array_is_valid', + ); + expect(MIGRATION_SQL).not.toMatch( + /\b(secret|token|credential|password)_/iu, + ); + }); +}); + +describeWithPostgres('plugin installation PostgreSQL constraints', () => { + beforeEach(() => { + requireSqlSuccess('DROP SCHEMA IF EXISTS plugin_integration CASCADE;'); + requireSqlSuccess(MIGRATION_SQL); + }); + + it('rejects impossible lifecycle, digest, capability, and UUID authority evidence', () => { + expectSqlFailure( + `INSERT INTO plugin_integration.plugin_installation_record ( + installation_id, workspace_id, installed_by_user_id, plugin_id, + plugin_contract_version, manifest_sha256, granted_capabilities, + installation_status, installed_at, revoked_at + ) VALUES ( + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + 'example.plugin', '1.0.0', repeat('a', 64), ARRAY['a'], + 'active', '2026-08-10T02:00:00.000Z', '2026-08-10T03:00:00.000Z' + );`, + 'plugin_installation_lifecycle_consistency', + ); + + expectSqlFailure( + `INSERT INTO plugin_integration.plugin_installation_record ( + installation_id, workspace_id, installed_by_user_id, plugin_id, + plugin_contract_version, manifest_sha256, granted_capabilities, + installation_status, installed_at, revoked_at + ) VALUES ( + '11111111-1111-4111-8111-111111111112', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + 'example.plugin', '1.0.0', repeat('a', 63), ARRAY['a'], + 'active', '2026-08-10T02:00:00.000Z', NULL + );`, + 'plugin_installation_manifest_sha256', + ); + + expectSqlFailure( + `INSERT INTO plugin_integration.plugin_installation_record ( + installation_id, workspace_id, installed_by_user_id, plugin_id, + plugin_contract_version, manifest_sha256, granted_capabilities, + installation_status, installed_at, revoked_at + ) VALUES ( + '11111111-1111-4111-8111-111111111113', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY(SELECT 'capability.' || lpad(value::text, 2, '0') + FROM generate_series(1, 33) AS value ORDER BY value), + 'active', '2026-08-10T02:00:00.000Z', NULL + );`, + 'plugin_installation_capability_count', + ); + + for (const fixture of [ + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111114', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY[''], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_capability_array', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111115', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY[NULL::text], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_capability_array', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111116', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY[repeat('x', 257)], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_capability_array', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-7111-8111-111111111117', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY['a'], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_id_uuid_v4', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111118', '22222222-2222-7222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY['a'], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_workspace_id_uuid_v4', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111119', '22222222-2222-4222-8222-222222222222', + '33333333-3333-7333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY['a'], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_user_id_uuid_v4', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111120', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY['a', 'a'], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_capability_array', + }, + { + sql: `INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111121', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY['b', 'a'], 'active', '2026-08-10T02:00:00.000Z', NULL);`, + constraint: 'plugin_installation_capability_array', + }, + ] as const) { + expectSqlFailure(fixture.sql, fixture.constraint); + } + + requireSqlSuccess(`INSERT INTO plugin_integration.plugin_installation_record + VALUES ('11111111-1111-4111-8111-111111111122', '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', 'example.plugin', '1.0.0', repeat('a', 64), + ARRAY['a', 'b'], 'active', '2026-08-10T02:00:00.000Z', NULL);`); + }); + + it('preserves one durable authority row across independent PostgreSQL client processes', () => { + requireSqlSuccess(` + INSERT INTO plugin_integration.plugin_installation_record ( + installation_id, workspace_id, installed_by_user_id, plugin_id, + plugin_contract_version, manifest_sha256, granted_capabilities, + installation_status, installed_at, revoked_at + ) VALUES ( + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + 'example.plugin', '1.0.0', repeat('a', 64), ARRAY['a'], + 'active', '2026-08-10T02:00:00.000Z', NULL + ); + `); + + const durable = requireSqlSuccess(` + SELECT installation_id || '|' || workspace_id || '|' || installed_by_user_id || '|' || installation_status + FROM plugin_integration.plugin_installation_record + WHERE installation_id = '11111111-1111-4111-8111-111111111111'::uuid; + `); + expect(durable).toBe( + '11111111-1111-4111-8111-111111111111|22222222-2222-4222-8222-222222222222|33333333-3333-4333-8333-333333333333|active', + ); + }); +}); \ No newline at end of file diff --git a/apps/integration-service/src/plugin-installation-repository.test.ts b/apps/integration-service/src/plugin-installation-repository.test.ts new file mode 100644 index 00000000..f9aec912 --- /dev/null +++ b/apps/integration-service/src/plugin-installation-repository.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import type { PluginInstallationRecord } from './plugin-installation'; +import { + PluginInstallationPersistenceEvidenceError, + PluginInstallationPersistenceValidationError, + PostgresPluginInstallationStore, + type PluginInstallationSqlClient, + type PluginInstallationSqlResult, +} from './plugin-installation-repository'; + +const INSTALLATION_ID = '11111111-1111-4111-8111-111111111111'; +const WORKSPACE_ID = '22222222-2222-4222-8222-222222222222'; +const USER_ID = '33333333-3333-4333-8333-333333333333'; +const OTHER_WORKSPACE_ID = '44444444-4444-4444-8444-444444444444'; +const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555'; +const INSTALLED_AT = '2026-08-10T02:00:00.000Z'; +const REPLAY_AT = '2026-08-10T02:30:00.000Z'; +const REVOKED_AT = '2026-08-10T03:00:00.000Z'; + +interface SqlCall { + readonly text: string; + readonly values: readonly unknown[]; +} + +class RecordingSqlClient implements PluginInstallationSqlClient { + readonly calls: SqlCall[] = []; + + constructor(private readonly rowsByCall: readonly (readonly unknown[])[]) {} + + async query( + text: string, + values: readonly unknown[] = [], + ): Promise> { + this.calls.push({ text, values }); + const rows = this.rowsByCall[this.calls.length - 1] ?? []; + return { rows: rows as readonly Row[], rowCount: rows.length }; + } +} + +function activeRow(overrides: Readonly> = {}) { + return { + installation_id: INSTALLATION_ID, + workspace_id: WORKSPACE_ID, + installed_by_user_id: USER_ID, + plugin_id: 'example.plugin', + plugin_contract_version: '1.0.0', + manifest_sha256: 'a'.repeat(64), + granted_capabilities: ['task.completed'], + installation_status: 'active', + installed_at: new Date(INSTALLED_AT), + revoked_at: null, + ...overrides, + }; +} + +function revokedRow(overrides: Readonly> = {}) { + return activeRow({ + installation_status: 'revoked', + revoked_at: new Date(REVOKED_AT), + ...overrides, + }); +} + +function candidate(installedAt = INSTALLED_AT): PluginInstallationRecord { + return { + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + pluginId: 'example.plugin', + pluginContractVersion: '1.0.0', + manifestSha256: 'a'.repeat(64), + grantedCapabilities: ['task.completed'], + status: 'active', + installedAt, + revokedAt: null, + }; +} + +describe('PostgresPluginInstallationStore', () => { + it('creates one exact workspace-and-installer-owned installation with parameterized SQL', async () => { + const client = new RecordingSqlClient([[activeRow()]]); + const store = new PostgresPluginInstallationStore(client); + + await expect(store.createIfAbsent(candidate())).resolves.toEqual(candidate()); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]?.text).toContain( + 'INSERT INTO plugin_integration.plugin_installation_record', + ); + expect(client.calls[0]?.text).toContain( + 'ON CONFLICT (installation_id) DO NOTHING', + ); + expect(client.calls[0]?.text).not.toMatch(/secret|token|credential/iu); + expect(client.calls[0]?.values).toEqual([ + INSTALLATION_ID, + WORKSPACE_ID, + USER_ID, + 'example.plugin', + '1.0.0', + 'a'.repeat(64), + ['task.completed'], + INSTALLED_AT, + ]); + }); + + it('returns the original durable timestamp after an exact scoped replay', async () => { + const client = new RecordingSqlClient([[], [activeRow()]]); + const store = new PostgresPluginInstallationStore(client); + + await expect(store.createIfAbsent(candidate(REPLAY_AT))).resolves.toEqual( + candidate(), + ); + expect(client.calls).toHaveLength(2); + expect(client.calls[1]?.text).toContain( + 'WHERE installation_id = $1::uuid', + ); + expect(client.calls[1]?.text).toContain('workspace_id = $2::uuid'); + expect(client.calls[1]?.text).toContain('installed_by_user_id = $3::uuid'); + expect(client.calls[1]?.values).toEqual([ + INSTALLATION_ID, + WORKSPACE_ID, + USER_ID, + ]); + }); + + it('bounds conflict-winner visibility retries instead of fabricating replay success', async () => { + const eventuallyVisible = new RecordingSqlClient([ + [], + [], + [], + [activeRow()], + ]); + const eventualStore = new PostgresPluginInstallationStore(eventuallyVisible); + await expect( + eventualStore.createIfAbsent(candidate(REPLAY_AT)), + ).resolves.toEqual(candidate()); + expect(eventuallyVisible.calls).toHaveLength(4); + + const neverVisible = new RecordingSqlClient([[], [], [], []]); + const exhaustedStore = new PostgresPluginInstallationStore(neverVisible); + await expect( + exhaustedStore.createIfAbsent(candidate(REPLAY_AT)), + ).rejects.toBeInstanceOf(PluginInstallationPersistenceEvidenceError); + expect(neverVisible.calls).toHaveLength(4); + }); + + it('looks up only exact workspace-and-installer evidence', async () => { + const matchingClient = new RecordingSqlClient([[activeRow()]]); + const matchingStore = new PostgresPluginInstallationStore(matchingClient); + await expect( + matchingStore.findById(INSTALLATION_ID, WORKSPACE_ID, USER_ID), + ).resolves.toEqual(candidate()); + expect(matchingClient.calls[0]?.values).toEqual([ + INSTALLATION_ID, + WORKSPACE_ID, + USER_ID, + ]); + expect(matchingClient.calls[0]?.text).toContain( + 'installed_by_user_id = $3::uuid', + ); + + const absentClient = new RecordingSqlClient([[]]); + const absentStore = new PostgresPluginInstallationStore(absentClient); + await expect( + absentStore.findById(INSTALLATION_ID, OTHER_WORKSPACE_ID, USER_ID), + ).resolves.toBeUndefined(); + await expect( + new PostgresPluginInstallationStore(new RecordingSqlClient([[]])).findById( + INSTALLATION_ID, + WORKSPACE_ID, + OTHER_USER_ID, + ), + ).resolves.toBeUndefined(); + }); + + it('fails closed when PostgreSQL returns evidence outside the requested authority scope', async () => { + const workspaceMismatch = new PostgresPluginInstallationStore( + new RecordingSqlClient([[activeRow({ workspace_id: OTHER_WORKSPACE_ID })]]), + ); + await expect( + workspaceMismatch.findById(INSTALLATION_ID, WORKSPACE_ID, USER_ID), + ).rejects.toBeInstanceOf(PluginInstallationPersistenceEvidenceError); + + const userMismatch = new PostgresPluginInstallationStore( + new RecordingSqlClient([[activeRow({ installed_by_user_id: OTHER_USER_ID })]]), + ); + await expect( + userMismatch.findById(INSTALLATION_ID, WORKSPACE_ID, USER_ID), + ).rejects.toBeInstanceOf(PluginInstallationPersistenceEvidenceError); + }); + + it('atomically revokes only installer-owned active authority and replays exact revoked evidence', async () => { + const activeClient = new RecordingSqlClient([[revokedRow()]]); + const activeStore = new PostgresPluginInstallationStore(activeClient); + await expect( + activeStore.revokeActive({ + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + revokedAt: REVOKED_AT, + }), + ).resolves.toEqual({ + ...candidate(), + status: 'revoked', + revokedAt: REVOKED_AT, + }); + expect(activeClient.calls[0]?.text).toContain( + "installation_status = 'active'", + ); + expect(activeClient.calls[0]?.text).toContain( + 'installed_by_user_id = $3::uuid', + ); + expect(activeClient.calls[0]?.values).toEqual([ + INSTALLATION_ID, + WORKSPACE_ID, + USER_ID, + REVOKED_AT, + ]); + + const replayClient = new RecordingSqlClient([[], [revokedRow()]]); + const replayStore = new PostgresPluginInstallationStore(replayClient); + await expect( + replayStore.revokeActive({ + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + revokedAt: REVOKED_AT, + }), + ).resolves.toEqual({ + ...candidate(), + status: 'revoked', + revokedAt: REVOKED_AT, + }); + expect(replayClient.calls[1]?.values).toEqual([ + INSTALLATION_ID, + WORKSPACE_ID, + USER_ID, + ]); + + const missingStore = new PostgresPluginInstallationStore( + new RecordingSqlClient([[], []]), + ); + await expect( + missingStore.revokeActive({ + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + revokedAt: REVOKED_AT, + }), + ).resolves.toBeUndefined(); + }); + + it('fails closed before SQL on malformed authority and rejects duplicate or corrupt durable evidence', async () => { + const malformedClient = new RecordingSqlClient([]); + const malformedStore = new PostgresPluginInstallationStore(malformedClient); + await expect( + malformedStore.createIfAbsent({ + ...candidate(), + workspaceId: 'not-a-uuid', + }), + ).rejects.toBeInstanceOf(PluginInstallationPersistenceValidationError); + await expect( + malformedStore.revokeActive({ + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: 'not-a-uuid', + revokedAt: REVOKED_AT, + }), + ).rejects.toBeInstanceOf(PluginInstallationPersistenceValidationError); + expect(malformedClient.calls).toHaveLength(0); + + for (const rows of [ + [activeRow(), activeRow()], + [activeRow({ workspace_id: OTHER_WORKSPACE_ID })], + [activeRow({ installed_by_user_id: OTHER_USER_ID })], + [activeRow({ manifest_sha256: 'not-a-digest' })], + ]) { + const store = new PostgresPluginInstallationStore( + new RecordingSqlClient([rows]), + ); + await expect(store.createIfAbsent(candidate())).rejects.toBeInstanceOf( + PluginInstallationPersistenceEvidenceError, + ); + } + }); +}); diff --git a/apps/integration-service/src/plugin-installation-repository.ts b/apps/integration-service/src/plugin-installation-repository.ts new file mode 100644 index 00000000..37657d72 --- /dev/null +++ b/apps/integration-service/src/plugin-installation-repository.ts @@ -0,0 +1,451 @@ +import type { + PluginInstallationRecord, + PluginInstallationStore, + RevokePluginInstallation, +} from './plugin-installation'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const ISO_INSTANT_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u; +const MAXIMUM_PLUGIN_ID_LENGTH = 256; +const MAXIMUM_CONTRACT_VERSION_LENGTH = 128; +const MAXIMUM_CAPABILITY_COUNT = 32; +const MAXIMUM_CAPABILITY_LENGTH = 256; +const MAXIMUM_REPLAY_ATTEMPTS = 3; +const REPLAY_DELAY_MILLISECONDS = 10; + +/** Result returned by the bounded installation SQL client. */ +export interface PluginInstallationSqlResult { + readonly rows: readonly Row[]; + readonly rowCount: number | null; +} + +/** Minimal fixed-query SQL authority used by the PostgreSQL installation store. */ +export interface PluginInstallationSqlClient { + query( + text: string, + values?: readonly unknown[], + ): Promise>; +} + +/** Rejects malformed input before it reaches PostgreSQL. */ +export class PluginInstallationPersistenceValidationError extends Error { + /** Creates a fixed credential-free validation failure. */ + constructor() { + super('Plugin installation persistence input is invalid'); + this.name = 'PluginInstallationPersistenceValidationError'; + } +} + +/** Rejects impossible, ambiguous, or corrupted persisted installation evidence. */ +export class PluginInstallationPersistenceEvidenceError extends Error { + /** Creates a fixed error without reflecting untrusted database values. */ + constructor() { + super('Persisted plugin installation evidence is invalid'); + this.name = 'PluginInstallationPersistenceEvidenceError'; + } +} + +/** Untrusted database row shape validated before it becomes installation evidence. */ +interface PluginInstallationRow { + installation_id: unknown; + workspace_id: unknown; + installed_by_user_id: unknown; + plugin_id: unknown; + plugin_contract_version: unknown; + manifest_sha256: unknown; + granted_capabilities: unknown; + installation_status: unknown; + installed_at: unknown; + revoked_at: unknown; +} + +/** Fails closed for malformed caller input without reflecting the bad value. */ +function invalidInput(): never { + throw new PluginInstallationPersistenceValidationError(); +} + +/** Fails closed when PostgreSQL returns ambiguous or impossible durable evidence. */ +function invalidEvidence(): never { + throw new PluginInstallationPersistenceEvidenceError(); +} + +/** Normalizes a caller-supplied UUIDv4 or rejects it before SQL execution. */ +function inputUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidInput(); + } + return value.toLowerCase(); +} + +/** Normalizes a persisted UUIDv4 or rejects corrupted database evidence. */ +function storedUuid(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidEvidence(); + } + return value.toLowerCase(); +} + +/** Parses one canonical UTC instant using the caller-selected fail-closed path. */ +function parseInstant(value: unknown, invalid: () => never): string { + const candidate = + value instanceof Date + ? value.toISOString() + : typeof value === 'string' + ? value + : ''; + if (!ISO_INSTANT_PATTERN.test(candidate)) { + return invalid(); + } + const parsed = new Date(candidate); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== candidate) { + return invalid(); + } + return candidate; +} + +/** Validates bounded caller text before it can become a SQL parameter. */ +function boundedInputText(value: unknown, maximumLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumLength || + CONTROL_CHARACTER_PATTERN.test(value) + ) { + return invalidInput(); + } + return value; +} + +/** Validates bounded persisted text before it can become trusted evidence. */ +function boundedStoredText(value: unknown, maximumLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumLength || + CONTROL_CHARACTER_PATTERN.test(value) + ) { + return invalidEvidence(); + } + return value; +} + +/** Validates a caller-supplied lowercase SHA-256 digest. */ +function inputDigest(value: unknown): string { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + return invalidInput(); + } + return value; +} + +/** Validates a persisted lowercase SHA-256 digest. */ +function storedDigest(value: unknown): string { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + return invalidEvidence(); + } + return value; +} + +/** Requires a bounded, unique, already-sorted caller capability set. */ +function inputCapabilities(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.length > MAXIMUM_CAPABILITY_COUNT) { + return invalidInput(); + } + const items = value.map((item) => + boundedInputText(item, MAXIMUM_CAPABILITY_LENGTH), + ); + const normalized = [...new Set(items)].sort(); + if ( + normalized.length !== items.length || + normalized.some((item, index) => item !== items[index]) + ) { + return invalidInput(); + } + return Object.freeze(normalized); +} + +/** Requires persisted capabilities to remain bounded, unique, and canonical. */ +function storedCapabilities(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.length > MAXIMUM_CAPABILITY_COUNT) { + return invalidEvidence(); + } + const items = value.map((item) => + boundedStoredText(item, MAXIMUM_CAPABILITY_LENGTH), + ); + const normalized = [...new Set(items)].sort(); + if ( + normalized.length !== items.length || + normalized.some((item, index) => item !== items[index]) + ) { + return invalidEvidence(); + } + return Object.freeze(normalized); +} + +/** Returns at most one row and rejects ambiguous duplicate durable evidence. */ +function oneOrUndefined(rows: readonly Row[]): Row | undefined { + if (rows.length > 1) { + return invalidEvidence(); + } + return rows[0]; +} + +/** Validates and freezes one new active installation before persistence. */ +function validateCreate(record: PluginInstallationRecord): PluginInstallationRecord { + if (record.status !== 'active' || record.revokedAt !== null) { + return invalidInput(); + } + return Object.freeze({ + installationId: inputUuid(record.installationId), + workspaceId: inputUuid(record.workspaceId), + installedByUserId: inputUuid(record.installedByUserId), + pluginId: boundedInputText(record.pluginId, MAXIMUM_PLUGIN_ID_LENGTH), + pluginContractVersion: boundedInputText( + record.pluginContractVersion, + MAXIMUM_CONTRACT_VERSION_LENGTH, + ), + manifestSha256: inputDigest(record.manifestSha256), + grantedCapabilities: inputCapabilities(record.grantedCapabilities), + status: 'active', + installedAt: parseInstant(record.installedAt, invalidInput), + revokedAt: null, + }); +} + +/** Validates revocation authority including installation, workspace, and installer. */ +function validateRevocation( + input: RevokePluginInstallation, +): RevokePluginInstallation { + return Object.freeze({ + installationId: inputUuid(input.installationId), + workspaceId: inputUuid(input.workspaceId), + installedByUserId: inputUuid(input.installedByUserId), + revokedAt: parseInstant(input.revokedAt, invalidInput), + }); +} + +/** + * Converts one untrusted row into a coherent installation record. + * + * Lifecycle/timestamp contradictions and malformed identifiers fail closed rather + * than becoming application authority. + */ +function parseRow(row: PluginInstallationRow): PluginInstallationRecord { + const status = + row.installation_status === 'active' || row.installation_status === 'revoked' + ? row.installation_status + : invalidEvidence(); + const installedAt = parseInstant(row.installed_at, invalidEvidence); + const revokedAt = + row.revoked_at === null + ? null + : parseInstant(row.revoked_at, invalidEvidence); + if ( + (status === 'active' && revokedAt !== null) || + (status === 'revoked' && revokedAt === null) || + (revokedAt !== null && + new Date(revokedAt).getTime() < new Date(installedAt).getTime()) + ) { + return invalidEvidence(); + } + return Object.freeze({ + installationId: storedUuid(row.installation_id), + workspaceId: storedUuid(row.workspace_id), + installedByUserId: storedUuid(row.installed_by_user_id), + pluginId: boundedStoredText(row.plugin_id, MAXIMUM_PLUGIN_ID_LENGTH), + pluginContractVersion: boundedStoredText( + row.plugin_contract_version, + MAXIMUM_CONTRACT_VERSION_LENGTH, + ), + manifestSha256: storedDigest(row.manifest_sha256), + grantedCapabilities: storedCapabilities(row.granted_capabilities), + status, + installedAt, + revokedAt, + }); +} + +/** + * Confirms that durable active authority is the same immutable installation. + * + * `installedAt` is intentionally not compared: on an exact idempotent replay the + * original durable timestamp is authoritative and a later retry timestamp must + * not rewrite history. + */ +function exactCandidate( + actual: PluginInstallationRecord, + expected: PluginInstallationRecord, +): boolean { + return ( + actual.installationId === expected.installationId && + actual.workspaceId === expected.workspaceId && + actual.installedByUserId === expected.installedByUserId && + actual.pluginId === expected.pluginId && + actual.pluginContractVersion === expected.pluginContractVersion && + actual.manifestSha256 === expected.manifestSha256 && + actual.status === 'active' && + actual.revokedAt === null && + actual.grantedCapabilities.length === expected.grantedCapabilities.length && + actual.grantedCapabilities.every( + (capability, index) => capability === expected.grantedCapabilities[index], + ) + ); +} + +/** Waits briefly before another conflict-winner visibility probe. */ +async function waitForReplayVisibility(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, REPLAY_DELAY_MILLISECONDS); + }); +} + +const RETURNING_COLUMNS = `installation_id, workspace_id, installed_by_user_id, + plugin_id, plugin_contract_version, manifest_sha256, + granted_capabilities, installation_status, installed_at, revoked_at`; + +/** PostgreSQL implementation of the host-owned plugin installation store. */ +export class PostgresPluginInstallationStore implements PluginInstallationStore { + /** Creates the store over a bounded parameterized SQL client. */ + constructor(private readonly client: PluginInstallationSqlClient) {} + + /** Creates an active installation or returns the exact durable replay winner. */ + async createIfAbsent( + record: PluginInstallationRecord, + ): Promise { + const safe = validateCreate(record); + const inserted = await this.client.query( + `INSERT INTO plugin_integration.plugin_installation_record ( + installation_id, workspace_id, installed_by_user_id, plugin_id, + plugin_contract_version, manifest_sha256, granted_capabilities, + installation_status, installed_at + ) VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7::text[], 'active', $8::timestamptz) + ON CONFLICT (installation_id) DO NOTHING + RETURNING ${RETURNING_COLUMNS}`, + [ + safe.installationId, + safe.workspaceId, + safe.installedByUserId, + safe.pluginId, + safe.pluginContractVersion, + safe.manifestSha256, + safe.grantedCapabilities, + safe.installedAt, + ], + ); + let row = oneOrUndefined(inserted.rows); + for ( + let attempt = 0; + !row && attempt < MAXIMUM_REPLAY_ATTEMPTS; + attempt += 1 + ) { + if (attempt > 0) { + await waitForReplayVisibility(); + } + const existing = await this.client.query( + `SELECT ${RETURNING_COLUMNS} + FROM plugin_integration.plugin_installation_record + WHERE installation_id = $1::uuid + AND workspace_id = $2::uuid + AND installed_by_user_id = $3::uuid + LIMIT 2`, + [safe.installationId, safe.workspaceId, safe.installedByUserId], + ); + row = oneOrUndefined(existing.rows); + } + if (!row) { + return invalidEvidence(); + } + const durable = parseRow(row); + if (!exactCandidate(durable, safe)) { + return invalidEvidence(); + } + return durable; + } + + /** Reads one installation only inside authenticated workspace-and-user scope. */ + async findById( + installationIdInput: string, + workspaceIdInput: string, + installedByUserIdInput: string, + ): Promise { + const installationId = inputUuid(installationIdInput); + const workspaceId = inputUuid(workspaceIdInput); + const installedByUserId = inputUuid(installedByUserIdInput); + const result = await this.client.query( + `SELECT ${RETURNING_COLUMNS} + FROM plugin_integration.plugin_installation_record + WHERE installation_id = $1::uuid + AND workspace_id = $2::uuid + AND installed_by_user_id = $3::uuid + LIMIT 2`, + [installationId, workspaceId, installedByUserId], + ); + const row = oneOrUndefined(result.rows); + if (!row) { + return undefined; + } + const durable = parseRow(row); + if ( + durable.installationId !== installationId || + durable.workspaceId !== workspaceId || + durable.installedByUserId !== installedByUserId + ) { + return invalidEvidence(); + } + return durable; + } + + /** Atomically revokes installer-owned authority or returns an exact revoked replay. */ + async revokeActive( + input: RevokePluginInstallation, + ): Promise { + const safe = validateRevocation(input); + const updated = await this.client.query( + `UPDATE plugin_integration.plugin_installation_record + SET installation_status = 'revoked', + revoked_at = $4::timestamptz + WHERE installation_id = $1::uuid + AND workspace_id = $2::uuid + AND installed_by_user_id = $3::uuid + AND installation_status = 'active' + AND installed_at <= $4::timestamptz + RETURNING ${RETURNING_COLUMNS}`, + [ + safe.installationId, + safe.workspaceId, + safe.installedByUserId, + safe.revokedAt, + ], + ); + let row = oneOrUndefined(updated.rows); + if (!row) { + const existing = await this.client.query( + `SELECT ${RETURNING_COLUMNS} + FROM plugin_integration.plugin_installation_record + WHERE installation_id = $1::uuid + AND workspace_id = $2::uuid + AND installed_by_user_id = $3::uuid + AND installation_status = 'revoked' + LIMIT 2`, + [safe.installationId, safe.workspaceId, safe.installedByUserId], + ); + row = oneOrUndefined(existing.rows); + } + if (!row) { + return undefined; + } + const durable = parseRow(row); + if ( + durable.installationId !== safe.installationId || + durable.workspaceId !== safe.workspaceId || + durable.installedByUserId !== safe.installedByUserId || + durable.status !== 'revoked' + ) { + return invalidEvidence(); + } + return durable; + } +} diff --git a/apps/integration-service/src/plugin-installation-tenant-lookup.test.ts b/apps/integration-service/src/plugin-installation-tenant-lookup.test.ts new file mode 100644 index 00000000..c3055a0e --- /dev/null +++ b/apps/integration-service/src/plugin-installation-tenant-lookup.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + PluginInstallationApplication, + type PluginInstallationRecord, + type PluginInstallationStore, + type RevokePluginInstallation, +} from './plugin-installation'; + +const INSTALLATION_ID = '11111111-1111-4111-8111-111111111111'; +const WORKSPACE_ID = '22222222-2222-4222-8222-222222222222'; +const USER_ID = '33333333-3333-4333-8333-333333333333'; +const OTHER_WORKSPACE_ID = '44444444-4444-4444-8444-444444444444'; +const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555'; + +function installation( + overrides: Partial = {}, +): PluginInstallationRecord { + return { + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ID, + pluginId: 'example.plugin', + pluginContractVersion: '1.0.0', + manifestSha256: 'a'.repeat(64), + grantedCapabilities: ['task.completed'], + status: 'active', + installedAt: '2026-08-10T02:00:00.000Z', + revokedAt: null, + ...overrides, + }; +} + +class ScopeRecordingStore implements PluginInstallationStore { + readonly lookupArguments: unknown[][] = []; + + constructor(private readonly lookupResult?: PluginInstallationRecord) {} + + async createIfAbsent( + record: PluginInstallationRecord, + ): Promise { + return record; + } + + async findById( + installationId: string, + workspaceId: string, + installedByUserId: string, + ): Promise { + this.lookupArguments.push([installationId, workspaceId, installedByUserId]); + return this.lookupResult; + } + + async revokeActive( + _input: RevokePluginInstallation, + ): Promise { + return undefined; + } +} + +describe('PluginInstallationApplication tenant lookup', () => { + it('returns matching durable evidence and passes both trusted authorities to persistence', async () => { + const expected = installation(); + const store = new ScopeRecordingStore(expected); + const application = new PluginInstallationApplication(store); + + await expect( + application.getInstallation( + { workspaceId: WORKSPACE_ID, actorUserId: USER_ID }, + INSTALLATION_ID, + ), + ).resolves.toEqual(expected); + + expect(store.lookupArguments).toEqual([ + [INSTALLATION_ID, WORKSPACE_ID, USER_ID], + ]); + }); + + it('fails closed when a persistence implementation returns another workspace or installer', async () => { + for (const mismatched of [ + installation({ workspaceId: OTHER_WORKSPACE_ID }), + installation({ installedByUserId: OTHER_USER_ID }), + ]) { + const store = new ScopeRecordingStore(mismatched); + const application = new PluginInstallationApplication(store); + + await expect( + application.getInstallation( + { workspaceId: WORKSPACE_ID, actorUserId: USER_ID }, + INSTALLATION_ID, + ), + ).resolves.toBeUndefined(); + expect(store.lookupArguments).toEqual([ + [INSTALLATION_ID, WORKSPACE_ID, USER_ID], + ]); + } + }); +}); diff --git a/apps/integration-service/src/plugin-installation-user-authority.test.ts b/apps/integration-service/src/plugin-installation-user-authority.test.ts new file mode 100644 index 00000000..4ca2c8f2 --- /dev/null +++ b/apps/integration-service/src/plugin-installation-user-authority.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + PluginInstallationApplication, + PluginInstallationError, + type PluginInstallationRecord, + type PluginInstallationStore, + type RevokePluginInstallation, +} from './plugin-installation'; + +const INSTALLATION_ID = '11111111-1111-4111-8111-111111111111'; +const WORKSPACE_ID = '22222222-2222-4222-8222-222222222222'; +const USER_ALPHA = '33333333-3333-4333-8333-333333333333'; +const USER_BETA = '44444444-4444-4444-8444-444444444444'; +const INSTALLED_AT = '2026-08-10T02:00:00.000Z'; +const REVOKED_AT = '2026-08-10T03:00:00.000Z'; + +function record( + status: 'active' | 'revoked' = 'active', +): PluginInstallationRecord { + return Object.freeze({ + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_ALPHA, + pluginId: 'example.plugin', + pluginContractVersion: '1.0.0', + manifestSha256: 'a'.repeat(64), + grantedCapabilities: Object.freeze(['lifeos.task.completed.v1']), + status, + installedAt: INSTALLED_AT, + revokedAt: status === 'revoked' ? REVOKED_AT : null, + }); +} + +class UserScopeRecordingStore implements PluginInstallationStore { + readonly lookupArguments: unknown[][] = []; + readonly revokeArguments: RevokePluginInstallation[] = []; + + async createIfAbsent( + input: PluginInstallationRecord, + ): Promise { + return input; + } + + async findById( + installationId: string, + workspaceId: string, + installedByUserId?: string, + ): Promise { + this.lookupArguments.push([installationId, workspaceId, installedByUserId]); + return record(); + } + + async revokeActive( + input: RevokePluginInstallation, + ): Promise { + this.revokeArguments.push(input); + return record('revoked'); + } +} + +describe('PluginInstallationApplication installer-user authority', () => { + it('passes the requesting user to persistence and hides another user installation in the same workspace', async () => { + const store = new UserScopeRecordingStore(); + const application = new PluginInstallationApplication(store); + + await expect( + application.getInstallation( + { workspaceId: WORKSPACE_ID, actorUserId: USER_BETA }, + INSTALLATION_ID, + ), + ).resolves.toBeUndefined(); + + expect(store.lookupArguments).toEqual([ + [INSTALLATION_ID, WORKSPACE_ID, USER_BETA], + ]); + }); + + it('binds revoke to the requesting user and rejects another user durable result', async () => { + const store = new UserScopeRecordingStore(); + const application = new PluginInstallationApplication( + store, + () => new Date(REVOKED_AT), + ); + + await expect( + application.revoke( + { workspaceId: WORKSPACE_ID, actorUserId: USER_BETA }, + INSTALLATION_ID, + ), + ).rejects.toBeInstanceOf(PluginInstallationError); + + expect(store.revokeArguments).toEqual([ + { + installationId: INSTALLATION_ID, + workspaceId: WORKSPACE_ID, + installedByUserId: USER_BETA, + revokedAt: REVOKED_AT, + }, + ]); + }); +}); diff --git a/apps/integration-service/src/plugin-installation.ts b/apps/integration-service/src/plugin-installation.ts index 7a0b085b..7423f91a 100644 --- a/apps/integration-service/src/plugin-installation.ts +++ b/apps/integration-service/src/plugin-installation.ts @@ -37,10 +37,16 @@ export interface PluginInstallationRecord { readonly revokedAt: string | null; } -/** Atomic revocation request owned by the host persistence boundary. */ +/** + * Atomic revocation request owned by the host persistence boundary. + * + * `installedByUserId` is derived from authenticated host context and remains part + * of the durable lookup/update scope; plugin input never supplies this authority. + */ export interface RevokePluginInstallation { readonly installationId: string; readonly workspaceId: string; + readonly installedByUserId: string; readonly revokedAt: string; } @@ -55,14 +61,19 @@ export interface PluginInstallationStore { * is not sufficient because concurrent conflicting grants must fail closed. */ createIfAbsent(record: PluginInstallationRecord): Promise; - findById(installationId: string): Promise; + /** Reads one installation only inside the authenticated workspace-and-user scope. */ + findById( + installationId: string, + workspaceId: string, + installedByUserId: string, + ): Promise; /** - * Atomically transitions one active workspace-owned installation to revoked, - * returning the already-revoked durable winner for an exact replay. + * Atomically transitions one active workspace-and-user-owned installation to + * revoked, returning the already-revoked durable winner for an exact replay. * - * Durable implementations must scope the update by installation, workspace and - * lifecycle state so concurrent revocations cannot create last-write-wins audit - * timestamps or revive a revoked record. + * Durable implementations must scope the update by installation, workspace, + * installing user and lifecycle state so another member of the same workspace + * cannot read or revoke authority they do not own. */ revokeActive(input: RevokePluginInstallation): Promise; } @@ -159,7 +170,7 @@ function sameInstallation( * * The application treats a validated manifest as requested capability intent only. * LifeOS persists the smaller host-approved grant set and never lets plugin input - * widen its own tenant authority. + * widen its own tenant or installer-user authority. */ export class PluginInstallationApplication { constructor( @@ -195,15 +206,23 @@ export class PluginInstallationApplication { return freezeRecord(durable); } - /** Returns an installation only inside the authenticated workspace boundary. */ + /** Returns an installation only inside authenticated workspace-and-user authority. */ async getInstallation( trustedContext: PluginInstallationContext, installationIdInput: string, ): Promise { const context = requireContext(trustedContext); const installationId = requireUuidV4(installationIdInput); - const existing = await this.store.findById(installationId); - if (!existing || existing.workspaceId !== context.workspaceId) { + const existing = await this.store.findById( + installationId, + context.workspaceId, + context.actorUserId, + ); + if ( + !existing || + existing.workspaceId !== context.workspaceId || + existing.installedByUserId !== context.actorUserId + ) { return undefined; } return freezeRecord(existing); @@ -219,12 +238,14 @@ export class PluginInstallationApplication { const durable = await this.store.revokeActive({ installationId, workspaceId: context.workspaceId, + installedByUserId: context.actorUserId, revokedAt: this.now().toISOString(), }); if ( !durable || durable.installationId !== installationId || durable.workspaceId !== context.workspaceId || + durable.installedByUserId !== context.actorUserId || durable.status !== 'revoked' || durable.revokedAt === null ) { diff --git a/docs/research/2026-08-10-plugin-installation-authority-standards.md b/docs/research/2026-08-10-plugin-installation-authority-standards.md index 5da765c7..8b1b0fc0 100644 --- a/docs/research/2026-08-10-plugin-installation-authority-standards.md +++ b/docs/research/2026-08-10-plugin-installation-authority-standards.md @@ -1,26 +1,47 @@ # Plugin installation authority standards note -Status: Implemented on active PR +Status: Partial ## Scope -This note records the standards basis for the first host-owned plugin installation authority slice. It does not claim that LifeOS has completed durable plugin persistence, secret storage, outbound delivery, operator APIs, or marketplace governance. +This note records the standards basis for LifeOS-owned plugin installation authority and the current durable persistence slice. The application authority from PR #151 is implemented on protected main. PR #156 adds PostgreSQL-backed installation evidence and proves database constraints plus persistence across independent PostgreSQL client processes; it does not yet claim full application restart/recovery, plugin secret storage, outbound delivery, operator APIs, retry/dead-letter handling, or marketplace governance. ## Decisions 1. Installation, workspace, and actor identifiers remain opaque UUIDv4 values. RFC 9562 is the current IETF UUID specification and obsoletes RFC 4122. LifeOS uses UUIDv4 as an identifier format, not as an authentication secret or proof of authorization. 2. A validated plugin manifest expresses requested integration intent. It is never installation authority. The authenticated LifeOS host persists only an explicitly granted subset of the manifest's declared subscriptions, preventing plugin input from widening its own effective permissions. -3. The grant boundary applies least privilege: the host grants only capabilities necessary for the approved installation. This is consistent with NIST SP 800-53 Rev. 5 control AC-6. NIST's current Rev. 5 catalog includes the Release 5.2.0 updates published in 2025; this slice does not claim NIST conformance or certification. +3. The grant boundary applies least privilege: the host grants only capabilities necessary for the approved installation. This is consistent with NIST SP 800-53 Rev. 5 control AC-6. NIST's current Rev. 5 publication includes the Release 5.2.0 supplemental update published in August 2025; that update does not replace the Rev. 5 publication or change this repository's no-certification stance. LifeOS does not claim NIST conformance or certification. 4. Installation evidence is host-owned and credential-free. The persisted contract records plugin identity, contract version, manifest digest, explicit grants, tenant ownership, installer identity, lifecycle status, and timestamps. Plugin-provided secrets are outside this slice. 5. Revocation is an authority transition, not deletion of evidence. The host retains the bounded installation record while future active use must treat a revoked installation as ineligible. -6. Exact replay of the same installation identifier and authority may be idempotent; conflicting reuse fails closed. Cross-workspace lookup does not disclose another tenant's installation record. +6. Exact replay of the same installation identifier and immutable authority may be idempotent; conflicting reuse fails closed. The durable original installation timestamp wins over a later retry timestamp, so retry timing cannot rewrite historical authority evidence. +7. PostgreSQL 18 is the current major PostgreSQL line used as the primary database-semantics reference for this decision. The installation store uses a unique primary-key conflict arbiter with `INSERT ... ON CONFLICT DO NOTHING`, followed by a bounded exact winner read when needed. Under PostgreSQL's default Read Committed isolation, successive statements can observe newly committed rows, so the replay winner probe is bounded to three reads with short delays rather than treating one temporarily invisible row as durable corruption. PostgreSQL documents `ON CONFLICT` as the concurrency-aware alternative to a uniqueness error and `RETURNING` as the direct mechanism for obtaining modified-row evidence. LifeOS still validates every returned row because database success is not by itself application-authority proof. +8. Read and revocation persistence is scoped by installation UUIDv4, workspace UUIDv4, and installer-user UUIDv4. Revocation additionally requires current `active` lifecycle state and a non-retroactive timestamp condition. A zero-row transition is not success; only an exact already-revoked row inside the same workspace-and-installer authority may satisfy replay. +9. The database migration uses descriptive multiword `snake_case` schema, table, column, and explicit index names and contains no plaintext secret/token/credential column. Future secret persistence must use a separately reviewed encrypted secret-handle/KMS contract. ## Acceptance evidence -The active PR must preserve RED-to-GREEN tests for explicit-grant narrowing, unauthorized-grant rejection, exact replay, conflicting identifier reuse, tenant isolation, revocation, malformed UUIDv4 inputs, and immutable returned evidence. Exact-head CI, security, coverage, and current review findings must pass before merge. Protected main remains the shipped source of truth. +Protected main already preserves RED-to-GREEN tests for explicit-grant narrowing, unauthorized-grant rejection, exact application replay, conflicting identifier reuse, tenant isolation, revocation, malformed UUIDv4 inputs, and immutable returned evidence from PR #151. + +PR #156 must additionally prove the migration naming contract; real PostgreSQL rejection of impossible lifecycle, malformed digest, and excessive-capability rows; persistence across independent PostgreSQL client processes; parameterized create/read/revoke SQL; workspace-and-installer scoping at the application and persistence boundaries; bounded conflict-winner visibility retries; preservation of the original installation timestamp across a later retry; rejection of malformed input before SQL; rejection of duplicate/corrupt rows; and absence of plaintext credential columns. Full application restart/recovery remains outside this slice until runtime composition exists. Exact-head CI, security, coverage, and current review findings must pass before merge. Protected main remains the shipped source of truth. ## APA 7 references Davis, K. R., Peabody, B. G., & Leach, P. J. (2024). *Universally Unique IDentifiers (UUIDs) (RFC 9562).* RFC Editor. https://doi.org/10.17487/RFC9562 +Publication status: Published IETF Standards Track RFC (Proposed Standard), May 2024. + Joint Task Force. (2020). *Security and privacy controls for information systems and organizations (NIST Special Publication 800-53 Rev. 5).* National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Publication status: Final NIST Special Publication; current Rev. 5 supplemental control release is 5.2.0 (August 27, 2025). + +PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation: INSERT.* https://www.postgresql.org/docs/18/sql-insert.html + +Publication status: Published PostgreSQL 18 release documentation. + +PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation: Returning data from modified rows.* https://www.postgresql.org/docs/18/dml-returning.html + +Publication status: Published PostgreSQL 18 release documentation. + +PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation: Transaction isolation.* https://www.postgresql.org/docs/18/transaction-iso.html + +Publication status: Published PostgreSQL 18 release documentation. diff --git a/turbo.json b/turbo.json index 4f939473..6bb3ff56 100644 --- a/turbo.json +++ b/turbo.json @@ -5,6 +5,7 @@ "AI_TEST_DATABASE_URL", "HABIT_DATABASE_URL", "IDENTITY_DATABASE_URL", + "INTEGRATION_DATABASE_URL", "NOTIFICATION_DATABASE_URL", "PLANNING_DATABASE_URL", "PRIVACY_DATABASE_URL"