From 46306d3fdb59ba38895b020a728741fe46c1e5d0 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Tue, 3 Dec 2024 14:57:45 -0600 Subject: [PATCH 1/9] Add connection_id to machine user API creation --- api/routes/ldap.ts | 3 ++- api/web/src/components/Connection/CertificateMachineUser.vue | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/routes/ldap.ts b/api/routes/ldap.ts index 014af37fc..a602d42c4 100644 --- a/api/routes/ldap.ts +++ b/api/routes/ldap.ts @@ -48,6 +48,7 @@ export default async function router(schema: Schema, config: Config) { name: Type.String(), description: Type.String(), agency_id: Type.Union([Type.Integer(), Type.Null()]), + connection_id: Type.Integer(), channels: Type.Array(Type.Integer(), { minItems: 1 }) @@ -74,7 +75,7 @@ export default async function router(schema: Schema, config: Config) { integration: { name: req.body.name, description: req.body.description, - management_url: config.API_URL + management_url: config.API_URL + `/connection/${req.body.connection_id}` } }); diff --git a/api/web/src/components/Connection/CertificateMachineUser.vue b/api/web/src/components/Connection/CertificateMachineUser.vue index e7f56c7e0..847982eee 100644 --- a/api/web/src/components/Connection/CertificateMachineUser.vue +++ b/api/web/src/components/Connection/CertificateMachineUser.vue @@ -168,6 +168,7 @@ export default { name: this.connection.name, description: this.connection.description, agency_id: this.connection.agency, + connection_id: this.connection.id, channels: this.selected.map((s) => { return s.id }) } }) From 1e5d839f97e209f214d953969e0b33a953ac1a72 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 05:29:04 -0600 Subject: [PATCH 2/9] Update integration in COTAK after connection created with machine user to set ID/url, and delete from COTAK when connection deleted. --- api/lib/external.ts | 58 +++++++- api/routes/connection.ts | 130 +++++++++++------- api/routes/ldap.ts | 28 ++-- .../Connection/CertificateMachineUser.vue | 6 +- api/web/src/components/ConnectionEdit.vue | 15 +- 5 files changed, 170 insertions(+), 67 deletions(-) diff --git a/api/lib/external.ts b/api/lib/external.ts index fc12c1f28..1de84b0db 100644 --- a/api/lib/external.ts +++ b/api/lib/external.ts @@ -9,9 +9,15 @@ export const Agency = Type.Object({ description: Type.Any() }); +export const Integration = Type.Object({ + id: Type.Number(), + name: Type.String(), +}); + export const MachineUser = Type.Object({ id: Type.Number(), email: Type.String(), + integrations: Type.Array(Integration), }); export const Channel = Type.Object({ @@ -79,6 +85,7 @@ export default class ExternalProvider { name: string; description: string; management_url: string; + active: boolean; } }): Promise> { const creds = await this.auth(); @@ -95,7 +102,6 @@ export default class ExternalProvider { active: true, integration: { ...body.integration, - active: true } } @@ -144,6 +150,56 @@ export default class ExternalProvider { return; } + async updateIntegrationConnectionId(uid: number, body: { + integration_id: number; + connection_id: number; + }): Promise { + const creds = await this.auth(); + + const url = new URL(`api/v1/proxy/integrations/etl/${body.integration_id}`, this.config.server.provider_url); + url.searchParams.append('proxy_user_id', String(uid)); + + const req = { + management_url: this.config.API_URL + `/connection/${body.connection_id}`, + external_identifier: body.connection_id, + active: true, + } + + const userres = await fetch(url, { + method: 'PATCH', + headers: { + Accept: 'application/json', + "Content-Type": "application/json", + "Authorization": `Bearer ${creds.token}` + }, + body: JSON.stringify(req) + }); + + if (!userres.ok) throw new Err(500, new Error(await userres.text()), 'External Integration Update Error'); + + return; + } + + async deleteIntegrationByConnectionId(uid: number, body: { + connection_id: number; + }): Promise { + const creds = await this.auth(); + + const url = new URL(`api/v1/proxy/integrations/etl/identifier/${body.connection_id}`, this.config.server.provider_url); + url.searchParams.append('proxy_user_id', String(uid)); + + await fetch(url, { + method: 'DELETE', + headers: { + Accept: 'application/json', + "Content-Type": "application/json", + "Authorization": `Bearer ${creds.token}` + } + }); + + return; + } + async agency(uid: number, agency_id: number): Promise> { const creds = await this.auth(); diff --git a/api/routes/connection.ts b/api/routes/connection.ts index 795dc7838..9eb15fe4d 100644 --- a/api/routes/connection.ts +++ b/api/routes/connection.ts @@ -1,13 +1,13 @@ import Err from '@openaddresses/batch-error'; -import { sql, and, inArray } from 'drizzle-orm'; +import {sql, and, inArray} from 'drizzle-orm'; import Config from '../lib/config.js'; import CW from '../lib/aws/metric.js'; -import Auth, { AuthResourceAccess } from '../lib/auth.js'; -import { X509Certificate } from 'crypto'; -import { Type } from '@sinclair/typebox' -import { StandardResponse, ConnectionResponse } from '../lib/types.js'; -import { Connection } from '../lib/schema.js'; -import { MachineConnConfig } from '../lib/connection-config.js'; +import Auth, {AuthResourceAccess} from '../lib/auth.js'; +import {X509Certificate} from 'crypto'; +import {Type} from '@sinclair/typebox' +import {StandardResponse, ConnectionResponse} from '../lib/types.js'; +import {Connection} from '../lib/schema.js'; +import {MachineConnConfig} from '../lib/connection-config.js'; import Schema from '@openaddresses/batch-schema'; import * as Default from '../lib/limits.js'; @@ -22,15 +22,15 @@ export default async function router(schema: Schema, config: Config) { limit: Default.Limit, page: Default.Page, order: Default.Order, - sort: Type.Optional(Type.String({ default: 'created', enum: Object.keys(Connection) })), + sort: Type.Optional(Type.String({default: 'created', enum: Object.keys(Connection)})), filter: Default.Filter }), res: Type.Object({ total: Type.Integer(), status: Type.Object({ - dead: Type.Integer({ description: 'The connection is not currently connected to a TAK server' }), - live: Type.Integer({ description: 'The connection is currently connected to a TAK server'}), - unknown: Type.Integer({ description: 'The status of the connection could not be determined'}), + dead: Type.Integer({description: 'The connection is not currently connected to a TAK server'}), + live: Type.Integer({description: 'The connection is currently connected to a TAK server'}), + unknown: Type.Integer({description: 'The status of the connection could not be determined'}), }), items: Type.Array(ConnectionResponse) }) @@ -40,10 +40,14 @@ export default async function router(schema: Schema, config: Config) { let where; if (profile.system_admin) { - where = sql`name ~* ${req.query.filter}` + where = sql`name + ~* + ${req.query.filter}` } else if (profile.agency_admin.length) { where = and( - sql`name ~* ${req.query.filter}`, + sql`name + ~* + ${req.query.filter}`, inArray(Connection.agency, profile.agency_admin) ); } else { @@ -60,13 +64,13 @@ export default async function router(schema: Schema, config: Config) { const json = { total: list.total, - status: { dead: 0, live: 0, unknown: 0 }, + status: {dead: 0, live: 0, unknown: 0}, items: list.items.map((conn) => { - const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); + const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); return { status: config.conns.status(conn.id), - certificate: { validFrom, validTo, subject }, + certificate: {validFrom, validTo, subject}, ...conn } }) @@ -90,23 +94,23 @@ export default async function router(schema: Schema, config: Config) { body: Type.Object({ name: Default.NameField, description: Default.DescriptionField, - enabled: Type.Optional(Type.Boolean({ default: true })), - agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({ minimum: 1 }))]), + enabled: Type.Optional(Type.Boolean({default: true})), + agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({minimum: 1}))]), + integrationId: Type.Union([Type.Integer(), Type.Null()]), auth: Type.Object({ - key: Type.String({ minLength: 1, maxLength: 4096 }), - cert: Type.String({ minLength: 1, maxLength: 4096 }) + key: Type.String({minLength: 1, maxLength: 4096}), + cert: Type.String({minLength: 1, maxLength: 4096}) }) }), res: ConnectionResponse }, async (req, res) => { try { const user = await Auth.as_user(config, req); + const profile = await config.models.Profile.from(user.email); if (!req.body.agency && user.access !== 'admin') { throw new Err(400, null, 'Only System Admins can create a server without an Agency Configured'); } else if (req.body.agency && user.access !== 'admin') { - const profile = await config.models.Profile.from(user.email); - if (!profile.agency_admin || !profile.agency_admin.includes(req.body.agency)) { throw new Err(400, null, 'Cannot create a connection for an Agency you are not an admin of'); } @@ -118,11 +122,20 @@ export default async function router(schema: Schema, config: Config) { if (conn.enabled) await config.conns.add(new MachineConnConfig(config, conn)); - const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); + const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); + + if (req.body.integrationId) { + if (!profile.id) throw new Err(400, null, 'External ID must be set on profile'); + + await config.external.updateIntegrationConnectionId(profile.id, { + connection_id: conn.id, + integration_id: req.body.integrationId + }) + } res.json({ status: config.conns.status(conn.id), - certificate: { validFrom, validTo, subject }, + certificate: {validFrom, validTo, subject}, ...conn }); } catch (err) { @@ -135,32 +148,33 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Update a connection', params: Type.Object({ - connectionid: Type.Integer({ minimum: 1 }) + connectionid: Type.Integer({minimum: 1}) }), body: Type.Object({ name: Type.Optional(Default.NameField), description: Type.Optional(Default.DescriptionField), enabled: Type.Optional(Type.Boolean()), - agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({ minimum: 1 }))]), + agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({minimum: 1}))]), auth: Type.Optional(Type.Object({ - key: Type.String({ minLength: 1, maxLength: 4096 }), - cert: Type.String({ minLength: 1, maxLength: 4096 }) + key: Type.String({minLength: 1, maxLength: 4096}), + cert: Type.String({minLength: 1, maxLength: 4096}) })) }), res: ConnectionResponse }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] + resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] }, req.params.connectionid); if (req.body.agency && await Auth.is_user(config, req)) { - const user = await Auth.as_user(config, req, { admin: true }); + const user = await Auth.as_user(config, req, {admin: true}); if (!user) throw new Err(400, null, 'Only System Admins can change an agency once a connection is created'); } const conn = await config.models.Connection.commit(req.params.connectionid, { - updated: sql`Now()`, + updated: sql`Now + ()`, ...req.body }); @@ -171,11 +185,11 @@ export default async function router(schema: Schema, config: Config) { await config.conns.add(new MachineConnConfig(config, conn)); } - const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); + const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); res.json({ status: config.conns.status(conn.id), - certificate: { validFrom, validTo, subject }, + certificate: {validFrom, validTo, subject}, ...conn }); } catch (err) { @@ -188,21 +202,21 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Get a connection', params: Type.Object({ - connectionid: Type.Integer({ minimum: 1 }) + connectionid: Type.Integer({minimum: 1}) }), res: ConnectionResponse }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] + resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] }, req.params.connectionid); const conn = await config.models.Connection.from(req.params.connectionid); - const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); + const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); res.json({ status: config.conns.status(conn.id), - certificate: { validFrom, validTo, subject }, + certificate: {validFrom, validTo, subject}, ...conn }); } catch (err) { @@ -215,13 +229,13 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Refresh a connection', params: Type.Object({ - connectionid: Type.Integer({ minimum: 1 }) + connectionid: Type.Integer({minimum: 1}) }), res: ConnectionResponse }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] + resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] }, req.params.connectionid); const conn = await config.models.Connection.from(req.params.connectionid); @@ -235,11 +249,11 @@ export default async function router(schema: Schema, config: Config) { await config.conns.add(new MachineConnConfig(config, conn)); } - const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); + const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); res.json({ status: config.conns.status(conn.id), - certificate: { validFrom, validTo, subject }, + certificate: {validFrom, validTo, subject}, ...conn }); } catch (err) { @@ -252,7 +266,7 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Delete a connection', params: Type.Object({ - connectionid: Type.Integer({ minimum: 1 }) + connectionid: Type.Integer({minimum: 1}) }), res: StandardResponse }, async (req, res) => { @@ -260,25 +274,41 @@ export default async function router(schema: Schema, config: Config) { await Auth.is_connection(config, req, {}, req.params.connectionid); if (await config.models.Layer.count({ - where: sql`connection = ${req.params.connectionid}` + where: sql`connection = + ${req.params.connectionid}` }) > 0) throw new Err(400, null, 'Connection has active Layers - Delete layers before deleting Connection'); if (await config.models.ConnectionSink.count({ - where: sql`connection = ${req.params.connectionid}` + where: sql`connection = + ${req.params.connectionid}` }) > 0) throw new Err(400, null, 'Connection has active Sinks - Delete Sinks before deleting Connection'); if (await config.models.Data.count({ - where: sql`connection = ${req.params.connectionid}` + where: sql`connection = + ${req.params.connectionid}` }) > 0) throw new Err(400, null, 'Connection has active Data Syncs - Delete Syncs before deleting Connection'); await config.models.Connection.delete(req.params.connectionid); await config.models.ConnectionToken.delete(sql` - connection = ${req.params.connectionid} + connection = + ${req.params.connectionid} `); config.conns.delete(req.params.connectionid); + const user = await Auth.as_user(config, req); + const profile = await config.models.Profile.from(user.email); + + if (profile.id) { + // I don't know how to figure out if the connection was created with a machine user and hence registered + // with COTAK, so just firing off the delete, which won't error out if no integration found. + await config.external.deleteIntegrationByConnectionId(profile.id, { + connection_id: req.params.connectionid, + }) + } + + res.json({ status: 200, message: 'Connection Deleted' @@ -293,7 +323,7 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Return Conn Success/Failure Stats', params: Type.Object({ - connectionid: Type.Integer({ minimum: 1 }) + connectionid: Type.Integer({minimum: 1}) }), res: Type.Object({ stats: Type.Array(Type.Object({ @@ -304,7 +334,7 @@ export default async function router(schema: Schema, config: Config) { }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] + resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] }, req.params.connectionid); const conn = await config.models.Connection.from(req.params.connectionid); @@ -315,7 +345,7 @@ export default async function router(schema: Schema, config: Config) { const map: Map = new Map(); if (!stats.length) { - res.json({ stats: [] }); + res.json({stats: []}); } else { const stat = stats[0]; @@ -338,7 +368,7 @@ export default async function router(schema: Schema, config: Config) { label: string; success: number; }> - } = { stats: [] } + } = {stats: []} for (const ts of ts_arr) { statsres.stats.push({ diff --git a/api/routes/ldap.ts b/api/routes/ldap.ts index a602d42c4..4d404d3cd 100644 --- a/api/routes/ldap.ts +++ b/api/routes/ldap.ts @@ -1,10 +1,10 @@ -import { Type } from '@sinclair/typebox' -import { randomUUID } from 'node:crypto'; +import {Type} from '@sinclair/typebox' +import {randomUUID} from 'node:crypto'; import Config from '../lib/config.js'; import Schema from '@openaddresses/batch-schema'; import Err from '@openaddresses/batch-error'; import Auth from '../lib/auth.js'; -import { Channel } from '../lib/external.js'; +import {Channel} from '../lib/external.js'; import TAKAPI, { APIAuthPassword, } from '../lib/tak-api.js'; @@ -16,7 +16,7 @@ export default async function router(schema: Schema, config: Config) { description: 'List Channels by proxy', query: Type.Object({ agency: Type.Optional(Type.Integer()), - filter: Type.String({ default: '' }) + filter: Type.String({default: ''}) }), res: Type.Object({ total: Type.Integer(), @@ -36,7 +36,7 @@ export default async function router(schema: Schema, config: Config) { res.json(list); } catch (err) { - Err.respond(err, res); + Err.respond(err, res); } }); @@ -48,14 +48,16 @@ export default async function router(schema: Schema, config: Config) { name: Type.String(), description: Type.String(), agency_id: Type.Union([Type.Integer(), Type.Null()]), - connection_id: Type.Integer(), channels: Type.Array(Type.Integer(), { minItems: 1 }) }), res: Type.Object({ - cert: Type.String(), - key: Type.String() + integrationId: Type.Union([Type.Integer(), Type.Null()]), + certificate: Type.Object({ + cert: Type.String(), + key: Type.String() + }) }) }, async (req, res) => { try { @@ -75,7 +77,8 @@ export default async function router(schema: Schema, config: Config) { integration: { name: req.body.name, description: req.body.description, - management_url: config.API_URL + `/connection/${req.body.connection_id}` + management_url: config.API_URL, + active: false, } }); @@ -93,9 +96,12 @@ export default async function router(schema: Schema, config: Config) { const certs = await api.Credentials.generate(); - res.json(certs) + res.json({ + integrationId: user.integrations.find(Boolean)?.id ?? null, + certificate: certs + }) } catch (err) { - Err.respond(err, res); + Err.respond(err, res); } }); } diff --git a/api/web/src/components/Connection/CertificateMachineUser.vue b/api/web/src/components/Connection/CertificateMachineUser.vue index 847982eee..89a07cecc 100644 --- a/api/web/src/components/Connection/CertificateMachineUser.vue +++ b/api/web/src/components/Connection/CertificateMachineUser.vue @@ -94,7 +94,7 @@ export default { connection: Object }, emits: [ - 'certs', + 'certs', 'integration', ], data: function() { return { @@ -168,13 +168,13 @@ export default { name: this.connection.name, description: this.connection.description, agency_id: this.connection.agency, - connection_id: this.connection.id, channels: this.selected.map((s) => { return s.id }) } }) this.loading.gen = true; - this.$emit('certs', res); + this.$emit('certs', res.certificate); + this.$emit('integration', res.integrationId) } } } diff --git a/api/web/src/components/ConnectionEdit.vue b/api/web/src/components/ConnectionEdit.vue index 54b93a261..975b02fe0 100644 --- a/api/web/src/components/ConnectionEdit.vue +++ b/api/web/src/components/ConnectionEdit.vue @@ -200,7 +200,8 @@ @@ -314,6 +315,7 @@ export default { agency: undefined, description: '', enabled: true, + integrationId: undefined, auth: { cert: '', key: '' } } } @@ -342,17 +344,26 @@ export default { this.connection.auth = { cert: '', key: '' } this.loading = false; }, + creation: function(certs) { + this.connection.auth.cert = certs.cert; + this.connection.auth.key = certs.key; + }, + integration: function(integrationId) { + this.connection.integrationId = integrationId; + }, marti: function(certs) { + this.connection.integrationId = null; this.connection.auth.cert = certs.cert; this.connection.auth.key = certs.key; }, p12upload: function(certs) { this.modal.upload = false; + this.connection.integrationId = null; this.connection.auth.cert = certs.cert; this.connection.auth.key = certs.key; }, create: async function() { - for (const field of ['name', 'description' ]) { + for (const field of ['name', 'description']) { if (!this.connection[field]) this.errors[field] = 'Cannot be empty'; else this.errors[field] = ''; } From 51af89197cd612ea29d361d29384ebf556ac7ba0 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 05:52:20 -0600 Subject: [PATCH 3/9] Attempting to fix formatting --- api/routes/connection.ts | 82 ++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/api/routes/connection.ts b/api/routes/connection.ts index 9eb15fe4d..b83e1baeb 100644 --- a/api/routes/connection.ts +++ b/api/routes/connection.ts @@ -1,13 +1,13 @@ import Err from '@openaddresses/batch-error'; -import {sql, and, inArray} from 'drizzle-orm'; +import { sql, and, inArray } from 'drizzle-orm'; import Config from '../lib/config.js'; import CW from '../lib/aws/metric.js'; -import Auth, {AuthResourceAccess} from '../lib/auth.js'; -import {X509Certificate} from 'crypto'; -import {Type} from '@sinclair/typebox' -import {StandardResponse, ConnectionResponse} from '../lib/types.js'; -import {Connection} from '../lib/schema.js'; -import {MachineConnConfig} from '../lib/connection-config.js'; +import Auth, { AuthResourceAccess } from '../lib/auth.js'; +import { X509Certificate } from 'crypto'; +import { Type } from '@sinclair/typebox' +import { StandardResponse, ConnectionResponse } from '../lib/types.js'; +import { Connection } from '../lib/schema.js'; +import { MachineConnConfig } from '../lib/connection-config.js'; import Schema from '@openaddresses/batch-schema'; import * as Default from '../lib/limits.js'; @@ -22,15 +22,15 @@ export default async function router(schema: Schema, config: Config) { limit: Default.Limit, page: Default.Page, order: Default.Order, - sort: Type.Optional(Type.String({default: 'created', enum: Object.keys(Connection)})), + sort: Type.Optional(Type.String({ default: 'created', enum: Object.keys(Connection) })), filter: Default.Filter }), res: Type.Object({ total: Type.Integer(), status: Type.Object({ - dead: Type.Integer({description: 'The connection is not currently connected to a TAK server'}), - live: Type.Integer({description: 'The connection is currently connected to a TAK server'}), - unknown: Type.Integer({description: 'The status of the connection could not be determined'}), + dead: Type.Integer({ description: 'The connection is not currently connected to a TAK server' }), + live: Type.Integer({ description: 'The connection is currently connected to a TAK server' }), + unknown: Type.Integer({ description: 'The status of the connection could not be determined' }), }), items: Type.Array(ConnectionResponse) }) @@ -64,13 +64,13 @@ export default async function router(schema: Schema, config: Config) { const json = { total: list.total, - status: {dead: 0, live: 0, unknown: 0}, + status: { dead: 0, live: 0, unknown: 0 }, items: list.items.map((conn) => { - const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); + const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); return { status: config.conns.status(conn.id), - certificate: {validFrom, validTo, subject}, + certificate: { validFrom, validTo, subject }, ...conn } }) @@ -94,12 +94,12 @@ export default async function router(schema: Schema, config: Config) { body: Type.Object({ name: Default.NameField, description: Default.DescriptionField, - enabled: Type.Optional(Type.Boolean({default: true})), - agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({minimum: 1}))]), + enabled: Type.Optional(Type.Boolean({ default: true })), + agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({ minimum: 1 }))]), integrationId: Type.Union([Type.Integer(), Type.Null()]), auth: Type.Object({ - key: Type.String({minLength: 1, maxLength: 4096}), - cert: Type.String({minLength: 1, maxLength: 4096}) + key: Type.String({ minLength: 1, maxLength: 4096 }), + cert: Type.String({ minLength: 1, maxLength: 4096 }) }) }), res: ConnectionResponse @@ -122,7 +122,7 @@ export default async function router(schema: Schema, config: Config) { if (conn.enabled) await config.conns.add(new MachineConnConfig(config, conn)); - const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); + const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); if (req.body.integrationId) { if (!profile.id) throw new Err(400, null, 'External ID must be set on profile'); @@ -135,7 +135,7 @@ export default async function router(schema: Schema, config: Config) { res.json({ status: config.conns.status(conn.id), - certificate: {validFrom, validTo, subject}, + certificate: { validFrom, validTo, subject }, ...conn }); } catch (err) { @@ -148,27 +148,27 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Update a connection', params: Type.Object({ - connectionid: Type.Integer({minimum: 1}) + connectionid: Type.Integer({ minimum: 1 }) }), body: Type.Object({ name: Type.Optional(Default.NameField), description: Type.Optional(Default.DescriptionField), enabled: Type.Optional(Type.Boolean()), - agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({minimum: 1}))]), + agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({ minimum: 1 }))]), auth: Type.Optional(Type.Object({ - key: Type.String({minLength: 1, maxLength: 4096}), - cert: Type.String({minLength: 1, maxLength: 4096}) + key: Type.String({ minLength: 1, maxLength: 4096 }), + cert: Type.String({ minLength: 1, maxLength: 4096 }) })) }), res: ConnectionResponse }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] + resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] }, req.params.connectionid); if (req.body.agency && await Auth.is_user(config, req)) { - const user = await Auth.as_user(config, req, {admin: true}); + const user = await Auth.as_user(config, req, { admin: true }); if (!user) throw new Err(400, null, 'Only System Admins can change an agency once a connection is created'); } @@ -185,11 +185,11 @@ export default async function router(schema: Schema, config: Config) { await config.conns.add(new MachineConnConfig(config, conn)); } - const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); + const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); res.json({ status: config.conns.status(conn.id), - certificate: {validFrom, validTo, subject}, + certificate: { validFrom, validTo, subject }, ...conn }); } catch (err) { @@ -202,21 +202,21 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Get a connection', params: Type.Object({ - connectionid: Type.Integer({minimum: 1}) + connectionid: Type.Integer({ minimum: 1 }) }), res: ConnectionResponse }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] + resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] }, req.params.connectionid); const conn = await config.models.Connection.from(req.params.connectionid); - const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); + const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); res.json({ status: config.conns.status(conn.id), - certificate: {validFrom, validTo, subject}, + certificate: { validFrom, validTo, subject }, ...conn }); } catch (err) { @@ -229,13 +229,13 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Refresh a connection', params: Type.Object({ - connectionid: Type.Integer({minimum: 1}) + connectionid: Type.Integer({ minimum: 1 }) }), res: ConnectionResponse }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] + resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] }, req.params.connectionid); const conn = await config.models.Connection.from(req.params.connectionid); @@ -249,11 +249,11 @@ export default async function router(schema: Schema, config: Config) { await config.conns.add(new MachineConnConfig(config, conn)); } - const {validFrom, validTo, subject} = new X509Certificate(conn.auth.cert); + const { validFrom, validTo, subject } = new X509Certificate(conn.auth.cert); res.json({ status: config.conns.status(conn.id), - certificate: {validFrom, validTo, subject}, + certificate: { validFrom, validTo, subject }, ...conn }); } catch (err) { @@ -266,7 +266,7 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Delete a connection', params: Type.Object({ - connectionid: Type.Integer({minimum: 1}) + connectionid: Type.Integer({ minimum: 1 }) }), res: StandardResponse }, async (req, res) => { @@ -323,7 +323,7 @@ export default async function router(schema: Schema, config: Config) { group: 'Connection', description: 'Return Conn Success/Failure Stats', params: Type.Object({ - connectionid: Type.Integer({minimum: 1}) + connectionid: Type.Integer({ minimum: 1 }) }), res: Type.Object({ stats: Type.Array(Type.Object({ @@ -334,7 +334,7 @@ export default async function router(schema: Schema, config: Config) { }, async (req, res) => { try { await Auth.is_connection(config, req, { - resources: [{access: AuthResourceAccess.CONNECTION, id: req.params.connectionid}] + resources: [{ access: AuthResourceAccess.CONNECTION, id: req.params.connectionid }] }, req.params.connectionid); const conn = await config.models.Connection.from(req.params.connectionid); @@ -345,7 +345,7 @@ export default async function router(schema: Schema, config: Config) { const map: Map = new Map(); if (!stats.length) { - res.json({stats: []}); + res.json({ stats: [] }); } else { const stat = stats[0]; @@ -368,7 +368,7 @@ export default async function router(schema: Schema, config: Config) { label: string; success: number; }> - } = {stats: []} + } = { stats: [] } for (const ts of ts_arr) { statsres.stats.push({ From bd9ab57a37e59575969f654392b154445e21ec19 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 05:53:15 -0600 Subject: [PATCH 4/9] Attempting to fix formatting ... again --- api/routes/ldap.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/routes/ldap.ts b/api/routes/ldap.ts index 4d404d3cd..8f340895b 100644 --- a/api/routes/ldap.ts +++ b/api/routes/ldap.ts @@ -1,10 +1,10 @@ -import {Type} from '@sinclair/typebox' -import {randomUUID} from 'node:crypto'; +import { Type } from '@sinclair/typebox' +import { randomUUID } from 'node:crypto'; import Config from '../lib/config.js'; import Schema from '@openaddresses/batch-schema'; import Err from '@openaddresses/batch-error'; import Auth from '../lib/auth.js'; -import {Channel} from '../lib/external.js'; +import { Channel } from '../lib/external.js'; import TAKAPI, { APIAuthPassword, } from '../lib/tak-api.js'; @@ -16,7 +16,7 @@ export default async function router(schema: Schema, config: Config) { description: 'List Channels by proxy', query: Type.Object({ agency: Type.Optional(Type.Integer()), - filter: Type.String({default: ''}) + filter: Type.String({ default: '' }) }), res: Type.Object({ total: Type.Integer(), From 0884e86a2401fe60f501419e9213661996d7bbb3 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 06:07:35 -0600 Subject: [PATCH 5/9] Added comment about delete_machine_users query param --- api/lib/external.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/lib/external.ts b/api/lib/external.ts index 1de84b0db..43a7c295b 100644 --- a/api/lib/external.ts +++ b/api/lib/external.ts @@ -185,6 +185,7 @@ export default class ExternalProvider { }): Promise { const creds = await this.auth(); + // there is a ?delete_machine_user query param you can add, if you want to delete any MU's associated with the integration const url = new URL(`api/v1/proxy/integrations/etl/identifier/${body.connection_id}`, this.config.server.provider_url); url.searchParams.append('proxy_user_id', String(uid)); From 116dd7b81d40c0e03bb4c6d7f8a11ec1a54caa0a Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 15:42:58 -0600 Subject: [PATCH 6/9] Fixing formatter issues --- api/routes/connection.ts | 43 +++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/api/routes/connection.ts b/api/routes/connection.ts index b83e1baeb..1859a458f 100644 --- a/api/routes/connection.ts +++ b/api/routes/connection.ts @@ -40,14 +40,10 @@ export default async function router(schema: Schema, config: Config) { let where; if (profile.system_admin) { - where = sql`name - ~* - ${req.query.filter}` + where = sql`name ~* ${req.query.filter}` } else if (profile.agency_admin.length) { where = and( - sql`name - ~* - ${req.query.filter}`, + sql`name ~* ${req.query.filter}`, inArray(Connection.agency, profile.agency_admin) ); } else { @@ -96,7 +92,7 @@ export default async function router(schema: Schema, config: Config) { description: Default.DescriptionField, enabled: Type.Optional(Type.Boolean({ default: true })), agency: Type.Union([Type.Null(), Type.Optional(Type.Integer({ minimum: 1 }))]), - integrationId: Type.Union([Type.Integer(), Type.Null()]), + integrationId: Type.Optional(Type.Integer()), auth: Type.Object({ key: Type.String({ minLength: 1, maxLength: 4096 }), cert: Type.String({ minLength: 1, maxLength: 4096 }) @@ -173,8 +169,7 @@ export default async function router(schema: Schema, config: Config) { } const conn = await config.models.Connection.commit(req.params.connectionid, { - updated: sql`Now - ()`, + updated: sql`Now()`, ...req.body }); @@ -274,38 +269,36 @@ export default async function router(schema: Schema, config: Config) { await Auth.is_connection(config, req, {}, req.params.connectionid); if (await config.models.Layer.count({ - where: sql`connection = - ${req.params.connectionid}` + where: sql`connection = ${req.params.connectionid}` }) > 0) throw new Err(400, null, 'Connection has active Layers - Delete layers before deleting Connection'); if (await config.models.ConnectionSink.count({ - where: sql`connection = - ${req.params.connectionid}` + where: sql`connection = ${req.params.connectionid}` }) > 0) throw new Err(400, null, 'Connection has active Sinks - Delete Sinks before deleting Connection'); if (await config.models.Data.count({ - where: sql`connection = - ${req.params.connectionid}` + where: sql`connection = ${req.params.connectionid}` }) > 0) throw new Err(400, null, 'Connection has active Data Syncs - Delete Syncs before deleting Connection'); await config.models.Connection.delete(req.params.connectionid); await config.models.ConnectionToken.delete(sql` - connection = - ${req.params.connectionid} + connection = ${req.params.connectionid} `); config.conns.delete(req.params.connectionid); - const user = await Auth.as_user(config, req); - const profile = await config.models.Profile.from(user.email); + if (config.externalProviderIsConfigured()) { + const user = await Auth.as_user(config, req); + const profile = await config.models.Profile.from(user.email); - if (profile.id) { - // I don't know how to figure out if the connection was created with a machine user and hence registered - // with COTAK, so just firing off the delete, which won't error out if no integration found. - await config.external.deleteIntegrationByConnectionId(profile.id, { - connection_id: req.params.connectionid, - }) + if (profile.id) { + // I don't know how to figure out if the connection was created with a machine user and hence registered + // with COTAK, so just firing off the delete, which won't error out if no integration found. + await config.external.deleteIntegrationByConnectionId(profile.id, { + connection_id: req.params.connectionid, + }) + } } From cdebb677c9b7d3f84b68d2c7ee3cfa5606d59322 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 15:45:25 -0600 Subject: [PATCH 7/9] Fixes per Nik for emit(), provider config check, naming convention --- api/lib/config.ts | 4 ++++ api/routes/agency.ts | 4 ++-- api/routes/ldap.ts | 12 ++++++------ .../Connection/CertificateMachineUser.vue | 8 +++++--- api/web/src/components/ConnectionEdit.vue | 13 +++++-------- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/api/lib/config.ts b/api/lib/config.ts index ba67e3b5b..c5bfadb9a 100644 --- a/api/lib/config.ts +++ b/api/lib/config.ts @@ -112,6 +112,10 @@ export default class Config { } } + externalProviderIsConfigured(): boolean { + return !!(this.server.provider_url && this.server.provider_secret && this.server.provider_client); + } + static async env(args: ConfigArgs): Promise { if (!process.env.AWS_REGION) { process.env.AWS_REGION = 'us-east-1'; diff --git a/api/routes/agency.ts b/api/routes/agency.ts index 5841654cc..7eb73320f 100644 --- a/api/routes/agency.ts +++ b/api/routes/agency.ts @@ -28,7 +28,7 @@ export default async function router(schema: Schema, config: Config) { const user = await Auth.as_user(config, req); const profile = await config.models.Profile.from(user.email); - if (!config.server.provider_url || !config.server.provider_secret || !config.server.provider_client) { + if (!config.externalProviderIsConfigured()) { res.json({ total: 0, items: [] }) } else if (!profile.id) { throw new Err(400, null, 'External ID must be set on profile'); @@ -55,7 +55,7 @@ export default async function router(schema: Schema, config: Config) { const user = await Auth.as_user(config, req); const profile = await config.models.Profile.from(user.email); - if (!config.server.provider_url || !config.server.provider_secret || !config.server.provider_client) { + if (!config.externalProviderIsConfigured()) { throw new Err(404, null, 'External API not configured'); } diff --git a/api/routes/ldap.ts b/api/routes/ldap.ts index 8f340895b..dab421331 100644 --- a/api/routes/ldap.ts +++ b/api/routes/ldap.ts @@ -26,7 +26,7 @@ export default async function router(schema: Schema, config: Config) { try { const profile = await Auth.as_profile(config, req); - if (!config.server.provider_url || !config.server.provider_secret || !config.server.provider_client) { + if (!config.externalProviderIsConfigured()) { throw new Err(400, null, 'External LDAP API not configured - Contact your administrator'); } @@ -53,8 +53,8 @@ export default async function router(schema: Schema, config: Config) { }) }), res: Type.Object({ - integrationId: Type.Union([Type.Integer(), Type.Null()]), - certificate: Type.Object({ + integrationId: Type.Optional(Type.Integer()), + auth: Type.Object({ cert: Type.String(), key: Type.String() }) @@ -63,7 +63,7 @@ export default async function router(schema: Schema, config: Config) { try { const profile = await Auth.as_profile(config, req); - if (!config.server.provider_url || !config.server.provider_secret || !config.server.provider_client) { + if (!config.externalProviderIsConfigured()) { throw new Err(400, null, 'External LDAP API not configured - Contact your administrator'); } @@ -97,8 +97,8 @@ export default async function router(schema: Schema, config: Config) { const certs = await api.Credentials.generate(); res.json({ - integrationId: user.integrations.find(Boolean)?.id ?? null, - certificate: certs + integrationId: user.integrations.find(Boolean)?.id ?? undefined, + auth: certs }) } catch (err) { Err.respond(err, res); diff --git a/api/web/src/components/Connection/CertificateMachineUser.vue b/api/web/src/components/Connection/CertificateMachineUser.vue index 89a07cecc..cc3a4c42a 100644 --- a/api/web/src/components/Connection/CertificateMachineUser.vue +++ b/api/web/src/components/Connection/CertificateMachineUser.vue @@ -94,7 +94,7 @@ export default { connection: Object }, emits: [ - 'certs', 'integration', + 'integration', ], data: function() { return { @@ -173,8 +173,10 @@ export default { }) this.loading.gen = true; - this.$emit('certs', res.certificate); - this.$emit('integration', res.integrationId) + this.$emit('integration', { + 'certs': res.auth, + 'integrationId': res.integrationId + }); } } } diff --git a/api/web/src/components/ConnectionEdit.vue b/api/web/src/components/ConnectionEdit.vue index 975b02fe0..44c511f12 100644 --- a/api/web/src/components/ConnectionEdit.vue +++ b/api/web/src/components/ConnectionEdit.vue @@ -200,8 +200,7 @@ @@ -344,12 +343,10 @@ export default { this.connection.auth = { cert: '', key: '' } this.loading = false; }, - creation: function(certs) { - this.connection.auth.cert = certs.cert; - this.connection.auth.key = certs.key; - }, - integration: function(integrationId) { - this.connection.integrationId = integrationId; + creation: function(integration) { + this.connection.integrationId = integration.integrationId; + this.connection.auth.cert = integration.certs.cert; + this.connection.auth.key = integration.certs.key; }, marti: function(certs) { this.connection.integrationId = null; From aee6a3bcc474f1847aafd390ee129e945c436819 Mon Sep 17 00:00:00 2001 From: Hugh Messenger Date: Wed, 11 Dec 2024 16:21:54 -0600 Subject: [PATCH 8/9] Debounce agency list filter in connection edit --- api/web/src/components/Connection/AgencySelect.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/web/src/components/Connection/AgencySelect.vue b/api/web/src/components/Connection/AgencySelect.vue index ca35ad400..135b83e8c 100644 --- a/api/web/src/components/Connection/AgencySelect.vue +++ b/api/web/src/components/Connection/AgencySelect.vue @@ -98,6 +98,7 @@ import { } from '@tak-ps/vue-tabler'; import { useProfileStore } from '/src/stores/profile.ts'; import { mapState } from 'pinia' +import { debounce } from 'lodash' export default { name: 'Agency', @@ -170,7 +171,7 @@ export default { fetch: async function() { this.selected = await std(`/api/agency/${this.modelValue}`); }, - listData: async function() { + listData: debounce(async function() { this.loading.list = true; const url = stdurl('/api/agency'); url.searchParams.append('filter', this.filter); @@ -184,7 +185,7 @@ export default { this.data = data; this.loading.list = false; - }, + }, 600), } }; From 5fc73a06761f52b0cafe35ce348adb8ebafa8725 Mon Sep 17 00:00:00 2001 From: ingalls Date: Fri, 13 Dec 2024 10:59:15 -0700 Subject: [PATCH 9/9] Use Radash --- api/web/package-lock.json | 10 ++++++++++ api/web/package.json | 5 +++-- api/web/src/components/Connection/AgencySelect.vue | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/api/web/package-lock.json b/api/web/package-lock.json index 5b40f4dc6..3b0b84bf1 100644 --- a/api/web/package-lock.json +++ b/api/web/package-lock.json @@ -30,6 +30,7 @@ "p12-pem": "^1.0.5", "phone": "^3.1.42", "pinia": "^2.1.7", + "radash": "^12.1.0", "sass": "^1.49.7", "sass-loader": "^16.0.0", "semver-sort": "^1.0.0", @@ -8742,6 +8743,15 @@ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "license": "ISC" }, + "node_modules/radash": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/radash/-/radash-12.1.0.tgz", + "integrity": "sha512-b0Zcf09AhqKS83btmUeYBS8tFK7XL2e3RvLmZcm0sTdF1/UUlHSsjXdCcWNxe7yfmAlPve5ym0DmKGtTzP6kVQ==", + "license": "MIT", + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/api/web/package.json b/api/web/package.json index 98a2bc70d..51dc73d51 100644 --- a/api/web/package.json +++ b/api/web/package.json @@ -34,6 +34,7 @@ "p12-pem": "^1.0.5", "phone": "^3.1.42", "pinia": "^2.1.7", + "radash": "^12.1.0", "sass": "^1.49.7", "sass-loader": "^16.0.0", "semver-sort": "^1.0.0", @@ -55,12 +56,12 @@ "eslint": "^9.0.0", "eslint-plugin-vue": "^9.0.0", "openapi-typescript": "^7.0.0", + "typescript": "5.6.2", "typescript-eslint": "^8.3.0", "vite": "^5.0.0", "vite-plugin-babel": "^1.2.0", "vite-plugin-pwa": "^0.21.0", - "vue-tsc": "2.0.29", - "typescript": "5.6.2" + "vue-tsc": "2.0.29" }, "browserslist": [ "> 1%", diff --git a/api/web/src/components/Connection/AgencySelect.vue b/api/web/src/components/Connection/AgencySelect.vue index 135b83e8c..122a9a03d 100644 --- a/api/web/src/components/Connection/AgencySelect.vue +++ b/api/web/src/components/Connection/AgencySelect.vue @@ -98,7 +98,7 @@ import { } from '@tak-ps/vue-tabler'; import { useProfileStore } from '/src/stores/profile.ts'; import { mapState } from 'pinia' -import { debounce } from 'lodash' +import { debounce } from 'radash' export default { name: 'Agency',