diff --git a/Makefile b/Makefile index 705dcc15..cb4a2640 100644 --- a/Makefile +++ b/Makefile @@ -69,4 +69,14 @@ run-test-prod: | build-prod docker compose -f .github/startup-test/docker-compose.yml down stop-dev: - CONTEXT="dev" docker compose -f docker-compose.dev.yml down \ No newline at end of file + CONTEXT="dev" docker compose -f docker-compose.dev.yml down + +run-postgres-dev: | build-dev + USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) CONTEXT="dev" docker compose -f docker-compose.postgres.yml up + +run-postgres-bash: | build-dev + USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) CONTEXT="dev" docker compose -f docker-compose.postgres.yml up -d + USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) CONTEXT="dev" docker compose -f docker-compose.postgres.yml exec auth sh + +stop-postgres: + CONTEXT="dev" docker compose -f docker-compose.postgres.yml down diff --git a/auth/package-lock.json b/auth/package-lock.json index ac684aba..8122388f 100644 --- a/auth/package-lock.json +++ b/auth/package-lock.json @@ -20,6 +20,7 @@ "final-di": "^1.0.10-alpha.1", "ioredis": "^5.7.0", "jsonwebtoken": "^9.0.2", + "pg": "^8.16.3", "response-time": "^2.3.4", "rest-app": "^1.0.0-alpha.9", "samlify": "2.10.1", @@ -46,7 +47,6 @@ "istanbul-badges-readme": "^1.9.0", "jest": "^29.7.0", "nodemon": "^3.1.10", - "pg": "^8.16.3", "prettier": "^3.6.2", "ts-jest": "^29.4.1", "ts-node": "^10.9.2", @@ -7731,7 +7731,6 @@ "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", - "dev": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -7758,21 +7757,18 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", - "dev": true, "optional": true }, "node_modules/pg-connection-string": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", - "dev": true, "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true, "engines": { "node": ">=4.0.0" } @@ -7781,7 +7777,6 @@ "version": "3.10.1", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", - "dev": true, "license": "MIT", "peerDependencies": { "pg": ">=8.0" @@ -7790,14 +7785,12 @@ "node_modules/pg-protocol": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "dev": true + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -7813,7 +7806,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dev": true, "dependencies": { "split2": "^4.1.0" } @@ -7932,7 +7924,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true, "engines": { "node": ">=4" } @@ -7941,7 +7932,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7950,7 +7940,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7959,7 +7948,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, "dependencies": { "xtend": "^4.0.0" }, @@ -8815,7 +8803,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", - "dev": true, "engines": { "node": ">= 10.x" } @@ -9799,7 +9786,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "engines": { "node": ">=0.4" } @@ -15504,7 +15490,6 @@ "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", - "dev": true, "requires": { "pg-cloudflare": "^1.2.7", "pg-connection-string": "^2.9.1", @@ -15518,39 +15503,33 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", - "dev": true, "optional": true }, "pg-connection-string": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", - "dev": true + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" }, "pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" }, "pg-pool": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", - "dev": true, "requires": {} }, "pg-protocol": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "dev": true + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" }, "pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, "requires": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -15563,7 +15542,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "dev": true, "requires": { "split2": "^4.1.0" } @@ -15649,26 +15627,22 @@ "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" }, "postgres-bytea": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "dev": true + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" }, "postgres-date": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" }, "postgres-interval": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, "requires": { "xtend": "^4.0.0" } @@ -16277,8 +16251,7 @@ "split2": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", - "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", - "dev": true + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==" }, "sprintf-js": { "version": "1.0.3", @@ -16954,8 +16927,7 @@ "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, "y18n": { "version": "5.0.8", diff --git a/auth/package.json b/auth/package.json index a983338c..685f4825 100644 --- a/auth/package.json +++ b/auth/package.json @@ -54,7 +54,6 @@ "istanbul-badges-readme": "^1.9.0", "jest": "^29.7.0", "nodemon": "^3.1.10", - "pg": "^8.16.3", "prettier": "^3.6.2", "ts-jest": "^29.4.1", "ts-node": "^10.9.2", @@ -75,6 +74,7 @@ "response-time": "^2.3.4", "rest-app": "^1.0.0-alpha.9", "samlify": "2.10.1", + "pg": "^8.16.3", "tslib": "^2.8.1" } } diff --git a/auth/src/adapter/postgres-adapter.ts b/auth/src/adapter/postgres-adapter.ts new file mode 100644 index 00000000..93be8b38 --- /dev/null +++ b/auth/src/adapter/postgres-adapter.ts @@ -0,0 +1,239 @@ +import { readFileSync } from 'fs'; +import { Pool, PoolClient } from 'pg'; + +import { Config } from '../config'; +import { GetManyAnswer, WriteRequest, EventType } from '../api/interfaces/datastore'; +import { Id } from '../core/key-transforms'; +import { Logger } from '../api/services/logger'; + +export class PostgresAdapter { + private pool: Pool; + + constructor() { + this.initializePool(); + } + + private initializePool(): void { + try { + let password: string; + if (Config.isDevMode()) { + password = 'openslides'; + Logger.debug('Using development password for PostgreSQL'); + } else { + password = readFileSync(Config.DATABASE_PASSWORD_FILE, 'utf8').trim(); + Logger.debug('Using password from file for PostgreSQL'); + } + this.pool = new Pool({ + host: Config.DATABASE_HOST, + port: Config.DATABASE_PORT, + database: Config.DATABASE_NAME, + user: Config.DATABASE_USER, + password: password, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000 + }); + + this.pool.on('error', (err: any) => { + Logger.error('PostgreSQL pool error:', err); + }); + + Logger.debug('PostgreSQL pool initialized'); + } catch (error) { + Logger.error('Failed to initialize PostgreSQL pool:', error); + throw error; + } + } + + public async get(collection: string, id: Id, mappedFields?: (keyof T)[]): Promise { + if (collection === 'organization') { + // Return empty object for organization requests since we don't support SAML yet + Logger.debug(`Collection '${collection}' is not supported yet - returning empty object`); + return {} as T; + } + if (collection !== 'user') { + throw new Error(`Collection '${collection}' is not supported`); + } + + const client = await this.pool.connect(); + try { + const fields = mappedFields ? this.mapFieldsToDbColumns(mappedFields as string[]).join(', ') : '*'; + const query = `SELECT ${fields} FROM user_t WHERE id = $1`; + const result = await client.query(query, [id]); + + if (result.rows.length === 0) { + return {} as T; + } + + const user = this.transformUserFromDb(result.rows[0]); + return user as T; + } finally { + client.release(); + } + } + + public async filter( + collection: string, + filterField: keyof T, + filterValue: string | number, + mappedFields?: (keyof T)[] + ): Promise> { + if (collection === 'organization') { + // Return empty result for organization requests since we don't support SAML yet + Logger.debug(`Collection '${collection}' is not supported yet - returning empty result`); + return {} as GetManyAnswer; + } + if (collection !== 'user') { + throw new Error(`Collection '${collection}' is not supported`); + } + + const client = await this.pool.connect(); + try { + const fields = mappedFields ? this.mapFieldsToDbColumns(mappedFields as string[]).join(', ') : '*'; + const dbField = this.mapFieldToDbColumn(filterField as string); + const query = `SELECT ${fields} FROM user_t WHERE ${dbField} = $1`; + const result = await client.query(query, [filterValue]); + + const answer: GetManyAnswer = {}; + for (const row of result.rows) { + const user = this.transformUserFromDb(row); + answer[user.id] = user as T; + } + + return answer; + } finally { + client.release(); + } + } + + public async exists( + collection: string, + filterField: keyof T, + filterValue: string | number + ): Promise<{ exists: boolean; position: number }> { + if (collection === 'organization') { + // Return false for organization existence checks since we don't support SAML yet + Logger.debug(`Collection '${collection}' is not supported yet - returning false`); + return { exists: false, position: 0 }; + } + if (collection !== 'user') { + throw new Error(`Collection '${collection}' is not supported`); + } + + const client = await this.pool.connect(); + try { + const dbField = this.mapFieldToDbColumn(filterField as string); + const query = `SELECT 1 FROM user_t WHERE ${dbField} = $1 LIMIT 1`; + const result = await client.query(query, [filterValue]); + + return { + exists: result.rows.length > 0, + position: 0 // Not relevant for direct database access + }; + } finally { + client.release(); + } + } + + public async write(writeRequest: WriteRequest): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + for (const event of writeRequest.events) { + if (event.type === EventType.UPDATE) { + const [collection, id] = event.fqid.split('/'); + if (collection === 'user') { + await this.updateUser(client, parseInt(id, 10), event.fields); + } else { + Logger.debug(`Unsupported collection for write: ${collection}`); + } + } else { + Logger.debug(`Unsupported event type: ${event.type}`); + } + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + Logger.error('Write operation failed:', error); + throw error; + } finally { + client.release(); + } + } + + private async updateUser(client: PoolClient, userId: number, fields: { [key: string]: unknown }): Promise { + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const [field, value] of Object.entries(fields)) { + if (field === 'meta_deleted') { + continue; // Skip meta_deleted field - doesn't exist in Postgres + } + const dbColumn = this.mapFieldToDbColumn(field); + if (dbColumn) { + updates.push(`${dbColumn} = $${paramIndex}`); + + // Handle last_login timestamp conversion + if (field === 'last_login' && typeof value === 'number') { + values.push(new Date(value * 1000).toISOString()); + } else { + values.push(value); + } + paramIndex++; + } + } + + if (updates.length === 0) { + Logger.debug('No valid fields to update'); + return; + } + + values.push(userId); + const query = `UPDATE user_t SET ${updates.join(', ')} WHERE id = $${paramIndex}`; + + await client.query(query, values); + Logger.debug(`Updated user ${userId} with fields:`, fields); + } + + private mapFieldToDbColumn(field: string): string { + const fieldMapping: { [key: string]: string } = { + id: 'id', + username: 'username', + password: 'password', + is_active: 'is_active', + email: 'email', + last_login: 'last_login', + saml_id: 'saml_id' + }; + + return fieldMapping[field] || field; + } + + private mapFieldsToDbColumns(fields: string[]): string[] { + return fields + .filter(field => field !== 'meta_deleted') // Exclude meta_deleted from SQL queries + .map(field => this.mapFieldToDbColumn(field)) + .filter(field => field && field !== ''); + } + + private transformUserFromDb(row: any): any { + return { + id: row.id, + username: row.username, + password: row.password, + is_active: row.is_active, + email: row.email, + last_login: row.last_login ? Math.floor(new Date(row.last_login).getTime() / 1000) : null, + saml_id: row.saml_id, + meta_deleted: false // In Postgres, deleted objects are actually deleted, so this is always false + }; + } + + public async close(): Promise { + await this.pool.end(); + Logger.debug('PostgreSQL pool closed'); + } +} diff --git a/auth/src/api/services/user-service.ts b/auth/src/api/services/user-service.ts index 6d593889..f91d2c9b 100644 --- a/auth/src/api/services/user-service.ts +++ b/auth/src/api/services/user-service.ts @@ -3,7 +3,7 @@ import { Id } from 'src/core/key-transforms'; import { HashingService } from './hashing-service'; import { Logger } from './logger'; -import { DatastoreAdapter } from '../../adapter/datastore-adapter'; +import { PostgresAdapter } from '../../adapter/postgres-adapter'; import { AuthenticationException } from '../../core/exceptions/authentication-exception'; import { User } from '../../core/models/user'; import { Datastore, EventType, GetManyAnswer } from '../interfaces/datastore'; @@ -15,7 +15,7 @@ const dummyPassword = '$argon2id$v=19$m=65536,t=3,p=4$IGvN2jGNrF5aPB5G85671w$zdaAc/BrqhD7edEz5bJroJ+M9xeZrUWao34lY8494cM'; export class UserService implements UserHandler { - @Factory(DatastoreAdapter) + @Factory(PostgresAdapter) private readonly _datastore: Datastore; @Factory(HashingService) diff --git a/auth/src/config/index.ts b/auth/src/config/index.ts index f6a4ba1a..22ad1814 100644 --- a/auth/src/config/index.ts +++ b/auth/src/config/index.ts @@ -9,9 +9,42 @@ const getUrl = (hostVar: string, portVar: string): string => { export class Config { public static readonly DATABASE_PATH = 'database/'; - public static readonly DATASTORE_READER = getUrl('DATASTORE_READER_HOST', 'DATASTORE_READER_PORT'); - public static readonly DATASTORE_WRITER = getUrl('DATASTORE_WRITER_HOST', 'DATASTORE_WRITER_PORT'); - public static readonly ACTION_URL = getUrl('ACTION_HOST', 'ACTION_PORT'); + + // Datastore URLs - only initialize if environment variables are available + public static get DATASTORE_READER(): string { + const host = process.env.DATASTORE_READER_HOST; + const port = process.env.DATASTORE_READER_PORT; + if (!host || !port) { + throw new Error('DATASTORE_READER_HOST or DATASTORE_READER_PORT is not defined.'); + } + return `http://${host}:${parseInt(port, 10)}`; + } + + public static get DATASTORE_WRITER(): string { + const host = process.env.DATASTORE_WRITER_HOST; + const port = process.env.DATASTORE_WRITER_PORT; + if (!host || !port) { + throw new Error('DATASTORE_WRITER_HOST or DATASTORE_WRITER_PORT is not defined.'); + } + return `http://${host}:${parseInt(port, 10)}`; + } + + public static get ACTION_URL(): string { + const host = process.env.ACTION_HOST; + const port = process.env.ACTION_PORT; + if (!host || !port) { + throw new Error('ACTION_HOST or ACTION_PORT is not defined.'); + } + return `http://${host}:${parseInt(port, 10)}`; + } + + // PostgreSQL configuration + public static readonly DATABASE_HOST = process.env.DATABASE_HOST || 'localhost'; + public static readonly DATABASE_PORT = parseInt(process.env.DATABASE_PORT || '5432', 10); + public static readonly DATABASE_NAME = process.env.DATABASE_NAME || 'openslides'; + public static readonly DATABASE_USER = process.env.DATABASE_USER || 'openslides'; + public static readonly DATABASE_PASSWORD_FILE = + process.env.DATABASE_PASSWORD_FILE || '/run/secrets/postgres_password'; public static readonly TOKEN_EXPIRATION_TIME = 600; diff --git a/auth/src/express/controllers/saml-controller.ts b/auth/src/express/controllers/saml-controller.ts index 11ec0035..92eb1d1c 100644 --- a/auth/src/express/controllers/saml-controller.ts +++ b/auth/src/express/controllers/saml-controller.ts @@ -3,7 +3,7 @@ import { Factory } from 'final-di'; import { OnGet, OnPost, Req, Res, RestController } from 'rest-app'; import * as samlify from 'samlify'; -import { DatastoreAdapter } from '../../adapter/datastore-adapter'; +import { PostgresAdapter } from '../../adapter/postgres-adapter'; import { AuthHandler } from '../../api/interfaces/auth-handler'; import { Datastore } from '../../api/interfaces/datastore'; import { HttpHandler, HttpResponse } from '../../api/interfaces/http-handler'; @@ -71,7 +71,7 @@ export class SamlController { @Factory(HttpService) private readonly _httpHandler: HttpHandler; - @Factory(DatastoreAdapter) + @Factory(PostgresAdapter) private readonly _datastore: Datastore; @Factory(SecretService) diff --git a/auth/tsconfig.json b/auth/tsconfig.json index dfe08254..7189e123 100644 --- a/auth/tsconfig.json +++ b/auth/tsconfig.json @@ -8,7 +8,7 @@ "sourceMap": false, "removeComments": true, "experimentalDecorators": true, - "target": "ES6", + "target": "ES2017", "esModuleInterop": true, "emitDecoratorMetadata": true, "moduleResolution": "node", diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml new file mode 100644 index 00000000..50b469f8 --- /dev/null +++ b/docker-compose.postgres.yml @@ -0,0 +1,51 @@ +services: + auth: + build: + target: "dev" + args: + CONTEXT: "dev" + image: openslides-auth-dev + restart: always + depends_on: + - postgres + - redis + environment: + - MESSAGE_BUS_HOST=redis + - MESSAGE_BUS_PORT=6379 + - AUTH_HOST=auth + - AUTH_PORT=9004 + - CACHE_HOST=redis + - CACHE_PORT=6379 + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_NAME=openslides + - DATABASE_USER=openslides + - OPENSLIDES_DEVELOPMENT=1 + volumes: + - ./auth/libraries:/app/libraries + - ./auth/src:/app/src + - ./auth/test:/app/test + ports: + - "9005:9004" + + postgres: + image: postgres:15 + environment: + - POSTGRES_USER=openslides + - POSTGRES_PASSWORD=openslides + - POSTGRES_DB=openslides + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + + redis: + image: redis:alpine + expose: + - 6379 + ports: + - "6380:6379" + +volumes: + postgres_data: diff --git a/init.sql b/init.sql new file mode 100644 index 00000000..258ca2ac --- /dev/null +++ b/init.sql @@ -0,0 +1,82 @@ +-- Create user table +CREATE TABLE user_t ( + id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL, + username varchar(256) NOT NULL, + member_number varchar(256), + saml_id varchar(256) CONSTRAINT minlength_saml_id CHECK (char_length(saml_id) >= 1), + pronoun varchar(32), + title varchar(256), + first_name varchar(256), + last_name varchar(256), + is_active boolean DEFAULT True, + is_physical_person boolean DEFAULT True, + password varchar(256), + default_password varchar(256), + can_change_own_password boolean DEFAULT True, + email varchar(256), + default_vote_weight decimal(16,6) CONSTRAINT minimum_default_vote_weight CHECK (default_vote_weight >= 0.000001) DEFAULT '1.000000', + last_email_sent timestamptz, + is_demo_user boolean, + last_login timestamptz, + external boolean, + gender_id integer, + organization_management_level varchar(256) CONSTRAINT enum_user_organization_management_level CHECK (organization_management_level IN ('superadmin', 'can_manage_organization', 'can_manage_users')), + home_committee_id integer, + organization_id integer GENERATED ALWAYS AS (1) STORED NOT NULL +); + +-- Insert test user with password 'password' hashed with argon2 +-- The hash below is for password 'password' +INSERT INTO user_t ( + username, + password, + email, + first_name, + last_name, + is_active +) VALUES ( + 'admin', + '$argon2id$v=19$m=65536,t=3,p=4$IGvN2jGNrF5aPB5G85671w$zdaAc/BrqhD7edEz5bJroJ+M9xeZrUWao34lY8494cM', + 'admin@example.com', + 'Admin', + 'User', + true +); + +-- Insert another test user +INSERT INTO user_t ( + username, + password, + email, + first_name, + last_name, + is_active +) VALUES ( + 'testuser', + '$argon2id$v=19$m=65536,t=3,p=4$IGvN2jGNrF5aPB5G85671w$zdaAc/BrqhD7edEz5bJroJ+M9xeZrUWao34lY8494cM', + 'test@example.com', + 'Test', + 'User', + true +); + +-- Insert an inactive user to test authentication rejection +INSERT INTO user_t ( + username, + password, + email, + first_name, + last_name, + is_active +) VALUES ( + 'inactive', + '$argon2id$v=19$m=65536,t=3,p=4$IGvN2jGNrF5aPB5G85671w$zdaAc/BrqhD7edEz5bJroJ+M9xeZrUWao34lY8494cM', + 'inactive@example.com', + 'Inactive', + 'User', + false +); + +-- Create an index on username for faster lookups +CREATE INDEX idx_user_username ON user_t(username); +CREATE INDEX idx_user_active ON user_t(is_active); diff --git a/postgres_password.txt b/postgres_password.txt new file mode 100644 index 00000000..a521f6fe --- /dev/null +++ b/postgres_password.txt @@ -0,0 +1 @@ +openslides