indraq_cli 1.5.8 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +1517 -1686
- package/cloud-api/.dockerignore +18 -0
- package/cloud-api/.env.example +14 -0
- package/cloud-api/Dockerfile +19 -0
- package/cloud-api/README.md +141 -0
- package/cloud-api/package-lock.json +2250 -0
- package/cloud-api/package.json +29 -0
- package/cloud-api/sql/001_init.sql +8 -0
- package/cloud-api/sql/002_user_providers_and_project_resources.sql +40 -0
- package/cloud-api/sql/003_environment_infrastructure.sql +19 -0
- package/cloud-api/sql/004_managed_aws_credentials.sql +20 -0
- package/cloud-api/sql/005_project_members_and_resource_grants.sql +22 -0
- package/cloud-api/sql/006_catalog_identity_password_security.sql +96 -0
- package/cloud-api/sql/007_aws_access_key_rotation.sql +16 -0
- package/cloud-api/src/index.ts +1254 -0
- package/cloud-api/tsconfig.json +18 -0
- package/dist/cli/create-program.d.ts.map +1 -1
- package/dist/cli/create-program.js +21 -3
- package/dist/cli/create-program.js.map +1 -1
- package/dist/cli/doctor.command.d.ts +1 -1
- package/dist/cli/doctor.command.d.ts.map +1 -1
- package/dist/cli/doctor.command.js +70 -12
- package/dist/cli/doctor.command.js.map +1 -1
- package/dist/cli/help-content.d.ts +1 -1
- package/dist/cli/help-content.d.ts.map +1 -1
- package/dist/cli/help-content.js +256 -177
- package/dist/cli/help-content.js.map +1 -1
- package/dist/modules/access/commands/access.command.d.ts +3 -0
- package/dist/modules/access/commands/access.command.d.ts.map +1 -0
- package/dist/modules/access/commands/access.command.js +226 -0
- package/dist/modules/access/commands/access.command.js.map +1 -0
- package/dist/modules/access/index.d.ts +3 -0
- package/dist/modules/access/index.d.ts.map +1 -0
- package/dist/modules/access/index.js +11 -0
- package/dist/modules/access/index.js.map +1 -0
- package/dist/modules/aws/commands/configure-aws.command.d.ts +2 -0
- package/dist/modules/aws/commands/configure-aws.command.d.ts.map +1 -0
- package/dist/modules/aws/commands/configure-aws.command.js +7 -0
- package/dist/modules/aws/commands/configure-aws.command.js.map +1 -0
- package/dist/modules/aws/commands/dns.command.d.ts +22 -0
- package/dist/modules/aws/commands/dns.command.d.ts.map +1 -0
- package/dist/modules/aws/commands/dns.command.js +229 -0
- package/dist/modules/aws/commands/dns.command.js.map +1 -0
- package/dist/modules/aws/commands/healthcheck.command.d.ts +22 -0
- package/dist/modules/aws/commands/healthcheck.command.d.ts.map +1 -0
- package/dist/modules/aws/commands/healthcheck.command.js +125 -0
- package/dist/modules/aws/commands/healthcheck.command.js.map +1 -0
- package/dist/modules/aws/commands/iam.command.d.ts +24 -0
- package/dist/modules/aws/commands/iam.command.d.ts.map +1 -0
- package/dist/modules/aws/commands/iam.command.js +219 -0
- package/dist/modules/aws/commands/iam.command.js.map +1 -0
- package/dist/modules/aws/config/aws-config.d.ts +23 -0
- package/dist/modules/aws/config/aws-config.d.ts.map +1 -0
- package/dist/modules/aws/config/aws-config.js +72 -0
- package/dist/modules/aws/config/aws-config.js.map +1 -0
- package/dist/modules/aws/index.d.ts +3 -0
- package/dist/modules/aws/index.d.ts.map +1 -0
- package/dist/modules/aws/index.js +54 -0
- package/dist/modules/aws/index.js.map +1 -0
- package/dist/modules/aws/services/aws-clients.d.ts +9 -0
- package/dist/modules/aws/services/aws-clients.d.ts.map +1 -0
- package/dist/modules/aws/services/aws-clients.js +43 -0
- package/dist/modules/aws/services/aws-clients.js.map +1 -0
- package/dist/modules/aws/services/route53-lookup.service.d.ts +9 -0
- package/dist/modules/aws/services/route53-lookup.service.d.ts.map +1 -0
- package/dist/modules/aws/services/route53-lookup.service.js +57 -0
- package/dist/modules/aws/services/route53-lookup.service.js.map +1 -0
- package/dist/modules/cloud/commands/cloud.command.d.ts +38 -0
- package/dist/modules/cloud/commands/cloud.command.d.ts.map +1 -0
- package/dist/modules/cloud/commands/cloud.command.js +443 -0
- package/dist/modules/cloud/commands/cloud.command.js.map +1 -0
- package/dist/modules/cloud/commands/credentials.command.d.ts +5 -0
- package/dist/modules/cloud/commands/credentials.command.d.ts.map +1 -0
- package/dist/modules/cloud/commands/credentials.command.js +25 -0
- package/dist/modules/cloud/commands/credentials.command.js.map +1 -0
- package/dist/modules/cloud/commands/environment.command.d.ts +6 -0
- package/dist/modules/cloud/commands/environment.command.d.ts.map +1 -0
- package/dist/modules/cloud/commands/environment.command.js +75 -0
- package/dist/modules/cloud/commands/environment.command.js.map +1 -0
- package/dist/modules/cloud/commands/provider.command.d.ts +3 -0
- package/dist/modules/cloud/commands/provider.command.d.ts.map +1 -0
- package/dist/modules/cloud/commands/provider.command.js +193 -0
- package/dist/modules/cloud/commands/provider.command.js.map +1 -0
- package/dist/modules/cloud/config/cloud-config.d.ts +19 -0
- package/dist/modules/cloud/config/cloud-config.d.ts.map +1 -0
- package/dist/modules/cloud/config/cloud-config.js +53 -0
- package/dist/modules/cloud/config/cloud-config.js.map +1 -0
- package/dist/modules/cloud/index.d.ts +3 -0
- package/dist/modules/cloud/index.d.ts.map +1 -0
- package/dist/modules/cloud/index.js +62 -0
- package/dist/modules/cloud/index.js.map +1 -0
- package/dist/modules/cloud/services/cloud-api.service.d.ts +17 -0
- package/dist/modules/cloud/services/cloud-api.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/cloud-api.service.js +61 -0
- package/dist/modules/cloud/services/cloud-api.service.js.map +1 -0
- package/dist/modules/cloud/services/cloud-aws.service.d.ts +23 -0
- package/dist/modules/cloud/services/cloud-aws.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/cloud-aws.service.js +26 -0
- package/dist/modules/cloud/services/cloud-aws.service.js.map +1 -0
- package/dist/modules/cloud/services/cloud-ghcr.service.d.ts +12 -0
- package/dist/modules/cloud/services/cloud-ghcr.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/cloud-ghcr.service.js +31 -0
- package/dist/modules/cloud/services/cloud-ghcr.service.js.map +1 -0
- package/dist/modules/cloud/services/cloud-jenkins.service.d.ts +29 -0
- package/dist/modules/cloud/services/cloud-jenkins.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/cloud-jenkins.service.js +236 -0
- package/dist/modules/cloud/services/cloud-jenkins.service.js.map +1 -0
- package/dist/modules/cloud/services/cloud-npm.service.d.ts +3 -0
- package/dist/modules/cloud/services/cloud-npm.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/cloud-npm.service.js +13 -0
- package/dist/modules/cloud/services/cloud-npm.service.js.map +1 -0
- package/dist/modules/cloud/services/environment.service.d.ts +25 -0
- package/dist/modules/cloud/services/environment.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/environment.service.js +21 -0
- package/dist/modules/cloud/services/environment.service.js.map +1 -0
- package/dist/modules/cloud/services/managed-aws-credential.service.d.ts +36 -0
- package/dist/modules/cloud/services/managed-aws-credential.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/managed-aws-credential.service.js +35 -0
- package/dist/modules/cloud/services/managed-aws-credential.service.js.map +1 -0
- package/dist/modules/cloud/services/project-cloud.service.d.ts +28 -0
- package/dist/modules/cloud/services/project-cloud.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/project-cloud.service.js +50 -0
- package/dist/modules/cloud/services/project-cloud.service.js.map +1 -0
- package/dist/modules/cloud/services/user-provider.service.d.ts +17 -0
- package/dist/modules/cloud/services/user-provider.service.d.ts.map +1 -0
- package/dist/modules/cloud/services/user-provider.service.js +25 -0
- package/dist/modules/cloud/services/user-provider.service.js.map +1 -0
- package/dist/modules/configure/configure.command.d.ts.map +1 -1
- package/dist/modules/configure/configure.command.js +17 -94
- package/dist/modules/configure/configure.command.js.map +1 -1
- package/dist/modules/deploy/commands/build.command.d.ts +1 -2
- package/dist/modules/deploy/commands/build.command.d.ts.map +1 -1
- package/dist/modules/deploy/commands/build.command.js +82 -124
- package/dist/modules/deploy/commands/build.command.js.map +1 -1
- package/dist/modules/deploy/index.d.ts.map +1 -1
- package/dist/modules/deploy/index.js +10 -30
- package/dist/modules/deploy/index.js.map +1 -1
- package/dist/modules/deploy/services/docker.service.d.ts +6 -3
- package/dist/modules/deploy/services/docker.service.d.ts.map +1 -1
- package/dist/modules/deploy/services/docker.service.js +8 -11
- package/dist/modules/deploy/services/docker.service.js.map +1 -1
- package/dist/modules/dockerfile/commands/create-dockerfile.command.d.ts +13 -0
- package/dist/modules/dockerfile/commands/create-dockerfile.command.d.ts.map +1 -0
- package/dist/modules/dockerfile/commands/create-dockerfile.command.js +39 -0
- package/dist/modules/dockerfile/commands/create-dockerfile.command.js.map +1 -0
- package/dist/modules/dockerfile/index.d.ts +3 -0
- package/dist/modules/dockerfile/index.d.ts.map +1 -0
- package/dist/modules/dockerfile/index.js +7 -0
- package/dist/modules/dockerfile/index.js.map +1 -0
- package/dist/modules/jenkins/commands/admin.command.d.ts +22 -0
- package/dist/modules/jenkins/commands/admin.command.d.ts.map +1 -0
- package/dist/modules/jenkins/commands/admin.command.js +128 -0
- package/dist/modules/jenkins/commands/admin.command.js.map +1 -0
- package/dist/modules/jenkins/commands/create-deployment.command.d.ts +13 -0
- package/dist/modules/jenkins/commands/create-deployment.command.d.ts.map +1 -0
- package/dist/modules/jenkins/commands/create-deployment.command.js +115 -0
- package/dist/modules/jenkins/commands/create-deployment.command.js.map +1 -0
- package/dist/modules/jenkins/index.d.ts +3 -0
- package/dist/modules/jenkins/index.d.ts.map +1 -0
- package/dist/modules/jenkins/index.js +28 -0
- package/dist/modules/jenkins/index.js.map +1 -0
- package/dist/modules/mobile/commands/build-mobile.command.d.ts.map +1 -1
- package/dist/modules/mobile/commands/build-mobile.command.js +4 -22
- package/dist/modules/mobile/commands/build-mobile.command.js.map +1 -1
- package/dist/modules/mobile/commands/configure-mobile.command.d.ts +1 -1
- package/dist/modules/mobile/commands/configure-mobile.command.d.ts.map +1 -1
- package/dist/modules/mobile/commands/configure-mobile.command.js +11 -9
- package/dist/modules/mobile/commands/configure-mobile.command.js.map +1 -1
- package/dist/modules/mobile/services/jenkins-mobile.service.d.ts +2 -2
- package/dist/modules/mobile/services/jenkins-mobile.service.d.ts.map +1 -1
- package/dist/modules/mobile/services/jenkins-mobile.service.js +1 -2
- package/dist/modules/mobile/services/jenkins-mobile.service.js.map +1 -1
- package/dist/modules/mobile/services/project.service.d.ts +2 -2
- package/dist/modules/mobile/services/project.service.d.ts.map +1 -1
- package/dist/modules/prebuild/commands/prebuild.command.d.ts +13 -0
- package/dist/modules/prebuild/commands/prebuild.command.d.ts.map +1 -0
- package/dist/modules/prebuild/commands/prebuild.command.js +507 -0
- package/dist/modules/prebuild/commands/prebuild.command.js.map +1 -0
- package/dist/modules/prebuild/index.d.ts +3 -0
- package/dist/modules/prebuild/index.d.ts.map +1 -0
- package/dist/modules/prebuild/index.js +19 -0
- package/dist/modules/prebuild/index.js.map +1 -0
- package/dist/modules/proxy/commands/configure-proxy.command.d.ts +2 -0
- package/dist/modules/proxy/commands/configure-proxy.command.d.ts.map +1 -0
- package/dist/modules/proxy/commands/configure-proxy.command.js +7 -0
- package/dist/modules/proxy/commands/configure-proxy.command.js.map +1 -0
- package/dist/modules/proxy/commands/npm-user.command.d.ts +28 -0
- package/dist/modules/proxy/commands/npm-user.command.d.ts.map +1 -0
- package/dist/modules/proxy/commands/npm-user.command.js +77 -0
- package/dist/modules/proxy/commands/npm-user.command.js.map +1 -0
- package/dist/modules/proxy/commands/proxy.command.d.ts +22 -0
- package/dist/modules/proxy/commands/proxy.command.d.ts.map +1 -0
- package/dist/modules/proxy/commands/proxy.command.js +173 -0
- package/dist/modules/proxy/commands/proxy.command.js.map +1 -0
- package/dist/modules/proxy/config/npm-config.d.ts +17 -0
- package/dist/modules/proxy/config/npm-config.d.ts.map +1 -0
- package/dist/modules/proxy/config/npm-config.js +50 -0
- package/dist/modules/proxy/config/npm-config.js.map +1 -0
- package/dist/modules/proxy/index.d.ts +3 -0
- package/dist/modules/proxy/index.d.ts.map +1 -0
- package/dist/modules/proxy/index.js +21 -0
- package/dist/modules/proxy/index.js.map +1 -0
- package/dist/modules/proxy/services/npm-api.service.d.ts +76 -0
- package/dist/modules/proxy/services/npm-api.service.d.ts.map +1 -0
- package/dist/modules/proxy/services/npm-api.service.js +82 -0
- package/dist/modules/proxy/services/npm-api.service.js.map +1 -0
- package/dist/modules/scaffold/commands/init.command.d.ts +6 -0
- package/dist/modules/scaffold/commands/init.command.d.ts.map +1 -0
- package/dist/modules/scaffold/commands/init.command.js +193 -0
- package/dist/modules/scaffold/commands/init.command.js.map +1 -0
- package/dist/modules/scaffold/config/project-metadata.d.ts +9 -0
- package/dist/modules/scaffold/config/project-metadata.d.ts.map +1 -0
- package/dist/modules/scaffold/config/project-metadata.js +75 -0
- package/dist/modules/scaffold/config/project-metadata.js.map +1 -0
- package/dist/modules/scaffold/index.d.ts +3 -0
- package/dist/modules/scaffold/index.d.ts.map +1 -0
- package/dist/modules/scaffold/index.js +7 -0
- package/dist/modules/scaffold/index.js.map +1 -0
- package/dist/modules/scaffold/services/docker-template.service.d.ts +3 -0
- package/dist/modules/scaffold/services/docker-template.service.d.ts.map +1 -0
- package/dist/modules/scaffold/services/docker-template.service.js +131 -0
- package/dist/modules/scaffold/services/docker-template.service.js.map +1 -0
- package/dist/modules/scaffold/services/filesystem.service.d.ts +7 -0
- package/dist/modules/scaffold/services/filesystem.service.d.ts.map +1 -0
- package/dist/modules/scaffold/services/filesystem.service.js +97 -0
- package/dist/modules/scaffold/services/filesystem.service.js.map +1 -0
- package/dist/modules/scaffold/services/project-template.service.d.ts +4 -0
- package/dist/modules/scaffold/services/project-template.service.d.ts.map +1 -0
- package/dist/modules/scaffold/services/project-template.service.js +82 -0
- package/dist/modules/scaffold/services/project-template.service.js.map +1 -0
- package/dist/modules/scaffold/types.d.ts +65 -0
- package/dist/modules/scaffold/types.d.ts.map +1 -0
- package/dist/modules/scaffold/types.js +3 -0
- package/dist/modules/scaffold/types.js.map +1 -0
- package/dist/modules/users/commands/user.command.d.ts +22 -0
- package/dist/modules/users/commands/user.command.d.ts.map +1 -0
- package/dist/modules/users/commands/user.command.js +350 -0
- package/dist/modules/users/commands/user.command.js.map +1 -0
- package/dist/modules/users/index.d.ts +3 -0
- package/dist/modules/users/index.d.ts.map +1 -0
- package/dist/modules/users/index.js +34 -0
- package/dist/modules/users/index.js.map +1 -0
- package/dist/modules/users/providers/aws-user.provider.d.ts +11 -0
- package/dist/modules/users/providers/aws-user.provider.d.ts.map +1 -0
- package/dist/modules/users/providers/aws-user.provider.js +129 -0
- package/dist/modules/users/providers/aws-user.provider.js.map +1 -0
- package/dist/modules/users/providers/jenkins-user.provider.d.ts +6 -0
- package/dist/modules/users/providers/jenkins-user.provider.d.ts.map +1 -0
- package/dist/modules/users/providers/jenkins-user.provider.js +97 -0
- package/dist/modules/users/providers/jenkins-user.provider.js.map +1 -0
- package/dist/modules/users/providers/npm-user.provider.d.ts +4 -0
- package/dist/modules/users/providers/npm-user.provider.d.ts.map +1 -0
- package/dist/modules/users/providers/npm-user.provider.js +47 -0
- package/dist/modules/users/providers/npm-user.provider.js.map +1 -0
- package/dist/shared/config/config-files.d.ts.map +1 -1
- package/dist/shared/config/config-files.js +14 -10
- package/dist/shared/config/config-files.js.map +1 -1
- package/dist/shared/github/github-api.service.d.ts.map +1 -1
- package/dist/shared/github/github-api.service.js +25 -37
- package/dist/shared/github/github-api.service.js.map +1 -1
- package/dist/shared/jenkins/jenkins.service.d.ts +2 -0
- package/dist/shared/jenkins/jenkins.service.d.ts.map +1 -1
- package/dist/shared/jenkins/jenkins.service.js +30 -1
- package/dist/shared/jenkins/jenkins.service.js.map +1 -1
- package/dist/shared/listing.d.ts +9 -0
- package/dist/shared/listing.d.ts.map +1 -0
- package/dist/shared/listing.js +29 -0
- package/dist/shared/listing.js.map +1 -0
- package/docs/ARCHITECTURE.md +29 -0
- package/docs/CREATE-DEPLOYMENT-reference.groovy +1105 -0
- package/package.json +76 -59
- package/templates/jenkins/CREATE-DEPLOYMENT.groovy +1105 -0
- package/templates/jenkins/Jenkinsfile-Mobile-App +759 -759
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import express, { type NextFunction, type Request, type Response } from 'express';
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
import jwt from 'jsonwebtoken';
|
|
5
|
+
import pg from 'pg';
|
|
6
|
+
import nodemailer from 'nodemailer';
|
|
7
|
+
import { IAMClient, CreateAccessKeyCommand, DeleteAccessKeyCommand, ListAccessKeysCommand } from '@aws-sdk/client-iam';
|
|
8
|
+
import { createCipheriv, createDecipheriv, randomBytes, randomInt } from 'node:crypto';
|
|
9
|
+
import { isIP } from 'node:net';
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const { Pool } = pg;
|
|
15
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(express.json({ limit: '1mb' }));
|
|
18
|
+
|
|
19
|
+
const jwtSecret = process.env.JWT_SECRET || '';
|
|
20
|
+
if (jwtSecret.length < 32) throw new Error('JWT_SECRET must be at least 32 characters.');
|
|
21
|
+
const masterHex = process.env.MASTER_KEY || '';
|
|
22
|
+
if (!/^[a-fA-F0-9]{64}$/.test(masterHex)) throw new Error('MASTER_KEY must be exactly 64 hex characters (32 bytes).');
|
|
23
|
+
const masterKey = Buffer.from(masterHex, 'hex');
|
|
24
|
+
|
|
25
|
+
type Role = 'user' | 'manager' | 'admin';
|
|
26
|
+
type Auth = { id: string; email: string; name: string; role: Role; mustChangePassword: boolean };
|
|
27
|
+
interface AuthedRequest extends Request { auth?: Auth }
|
|
28
|
+
|
|
29
|
+
const encrypt = (text: string): string => {
|
|
30
|
+
const iv = randomBytes(12);
|
|
31
|
+
const cipher = createCipheriv('aes-256-gcm', masterKey, iv);
|
|
32
|
+
const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
|
33
|
+
return [iv, cipher.getAuthTag(), body].map((part) => part.toString('base64url')).join('.');
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const decrypt = (value: string): string => {
|
|
37
|
+
const [ivString, tagString, bodyString] = value.split('.');
|
|
38
|
+
if (!ivString || !tagString || !bodyString) throw new Error('Invalid encrypted value');
|
|
39
|
+
const decipher = createDecipheriv('aes-256-gcm', masterKey, Buffer.from(ivString, 'base64url'));
|
|
40
|
+
decipher.setAuthTag(Buffer.from(tagString, 'base64url'));
|
|
41
|
+
return Buffer.concat([decipher.update(Buffer.from(bodyString, 'base64url')), decipher.final()]).toString('utf8');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const auth = async (req: AuthedRequest, res: Response, next: NextFunction): Promise<void> => {
|
|
45
|
+
const rawHeader = req.headers.authorization;
|
|
46
|
+
const header = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
47
|
+
if (!header?.startsWith('Bearer ')) { res.status(401).json({ error: 'Authentication required' }); return; }
|
|
48
|
+
try {
|
|
49
|
+
const token = jwt.verify(header.slice(7), jwtSecret) as Partial<Auth>;
|
|
50
|
+
if (!token.id) { res.status(401).json({ error: 'Invalid or expired token' }); return; }
|
|
51
|
+
// Roles and account state are resolved from the database on every request.
|
|
52
|
+
// This makes admin/manager demotion and account disabling effective
|
|
53
|
+
// immediately instead of trusting a stale role embedded in a 12-hour JWT.
|
|
54
|
+
const current = await pool.query('SELECT id,email,name,role,is_active,must_change_password FROM users WHERE id=$1', [token.id]);
|
|
55
|
+
const user = current.rows[0];
|
|
56
|
+
if (!user || !user.is_active || !['user','manager','admin'].includes(user.role)) {
|
|
57
|
+
res.status(401).json({ error: 'Account is disabled or no longer available' }); return;
|
|
58
|
+
}
|
|
59
|
+
req.auth = { id: user.id, email: user.email, name: user.name, role: user.role as Role, mustChangePassword: !!user.must_change_password };
|
|
60
|
+
if (user.must_change_password && !['/auth/me','/auth/first-login-password'].includes(req.path)) {
|
|
61
|
+
res.status(428).json({ error: 'Temporary password must be changed before using IndraQ. Run `indraq login` and complete the first-login password change.' }); return;
|
|
62
|
+
}
|
|
63
|
+
next();
|
|
64
|
+
} catch {
|
|
65
|
+
res.status(401).json({ error: 'Invalid or expired token' });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const allow = (...roles: Role[]) => [
|
|
70
|
+
auth,
|
|
71
|
+
(req: AuthedRequest, res: Response, next: NextFunction) => roles.includes(req.auth!.role)
|
|
72
|
+
? next()
|
|
73
|
+
: res.status(403).json({ error: 'Insufficient role' })
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
const audit = async (
|
|
77
|
+
actor: string | undefined,
|
|
78
|
+
action: string,
|
|
79
|
+
type: string,
|
|
80
|
+
id: string | undefined,
|
|
81
|
+
reason: string | undefined,
|
|
82
|
+
metadata: unknown = {}
|
|
83
|
+
): Promise<void> => {
|
|
84
|
+
await pool.query(
|
|
85
|
+
'INSERT INTO audit_logs(actor_id,action,resource_type,resource_id,reason,metadata) VALUES($1,$2,$3,$4,$5,$6)',
|
|
86
|
+
[actor || null, action, type, id || null, reason || null, metadata]
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const routeParam = (value: string | string[] | undefined): string => {
|
|
91
|
+
if (!value) throw new Error('Missing route parameter');
|
|
92
|
+
return Array.isArray(value) ? (value[0] ?? '') : value;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const requireReason = (req: Request, res: Response): string | null => {
|
|
96
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : '';
|
|
97
|
+
if (reason.length < 3) {
|
|
98
|
+
res.status(400).json({ error: 'A reason of at least 3 characters is required.' });
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return reason;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
105
|
+
|
|
106
|
+
const ensureUuid = (value: string, label = 'ID'): string => {
|
|
107
|
+
if (!UUID_RE.test(value)) {
|
|
108
|
+
const error = new Error(`${label} must be a valid UUID. If the local project link is damaged, run \`indraq project link\`.`) as Error & { statusCode?: number };
|
|
109
|
+
error.statusCode = 400;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const listQuery = (req: Request, defaultLimit = 10, maximum = 5000): { search: string; limit: number; all: boolean } => {
|
|
116
|
+
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
|
|
117
|
+
const all = req.query.all === 'true' || req.query.all === '1';
|
|
118
|
+
const parsed = Number(typeof req.query.limit === 'string' ? req.query.limit : defaultLimit);
|
|
119
|
+
const limit = all ? maximum : Math.max(1, Math.min(maximum, Number.isInteger(parsed) ? parsed : defaultLimit));
|
|
120
|
+
return { search, limit, all };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type ProjectAccess = 'view' | 'write';
|
|
124
|
+
const projectAccessLevel = async (projectIdRaw: string, user: Auth): Promise<ProjectAccess | null> => {
|
|
125
|
+
const projectId = ensureUuid(projectIdRaw, 'Project ID');
|
|
126
|
+
const result = await pool.query(
|
|
127
|
+
`SELECT p.id,p.created_by,pm.role,pm.access_level
|
|
128
|
+
FROM projects p
|
|
129
|
+
LEFT JOIN project_members pm ON pm.project_id=p.id AND pm.user_id=$2
|
|
130
|
+
WHERE p.id=$1
|
|
131
|
+
LIMIT 1`,
|
|
132
|
+
[projectId, user.id]
|
|
133
|
+
);
|
|
134
|
+
if (!result.rowCount) return null;
|
|
135
|
+
if (user.role === 'admin' || user.role === 'manager') return 'write';
|
|
136
|
+
const row = result.rows[0];
|
|
137
|
+
if (row.created_by === user.id || row.role === 'owner' || row.access_level === 'write') return 'write';
|
|
138
|
+
// Every authenticated organization user can discover/read every project.
|
|
139
|
+
return 'view';
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const hasProjectAccess = async (projectId: string, user: Auth): Promise<boolean> =>
|
|
143
|
+
(await projectAccessLevel(projectId, user)) !== null;
|
|
144
|
+
|
|
145
|
+
const hasProjectWriteAccess = async (projectId: string, user: Auth): Promise<boolean> =>
|
|
146
|
+
(await projectAccessLevel(projectId, user)) === 'write';
|
|
147
|
+
|
|
148
|
+
const hasProjectResourceAccess = async (resourceIdRaw: string, user: Auth, manage = false): Promise<boolean> => {
|
|
149
|
+
const resourceId = ensureUuid(resourceIdRaw, 'Project resource ID');
|
|
150
|
+
if (user.role === 'admin' || user.role === 'manager') return true;
|
|
151
|
+
const resource = await pool.query('SELECT project_id,created_by FROM project_resources WHERE id=$1', [resourceId]);
|
|
152
|
+
if (!resource.rowCount) return false;
|
|
153
|
+
const access = await projectAccessLevel(resource.rows[0].project_id, user);
|
|
154
|
+
if (!manage) return access !== null;
|
|
155
|
+
if (access === 'write' || resource.rows[0].created_by === user.id) return true;
|
|
156
|
+
const grant = await pool.query(
|
|
157
|
+
"SELECT 1 FROM project_resource_grants WHERE project_resource_id=$1 AND user_id=$2 AND permission='manage' LIMIT 1",
|
|
158
|
+
[resourceId, user.id]
|
|
159
|
+
);
|
|
160
|
+
return !!grant.rowCount;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const providerName = (raw: string): string => {
|
|
164
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
165
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value)) throw new Error('Invalid provider name');
|
|
166
|
+
return value;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const resourceType = (raw: string): string => {
|
|
170
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
171
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value)) throw new Error('Invalid resource type');
|
|
172
|
+
return value;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const environmentName = (raw: unknown): string => {
|
|
176
|
+
const value = String(raw || 'dev').trim().toLowerCase();
|
|
177
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value)) throw new Error('Invalid environment');
|
|
178
|
+
return value;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
const providerIntegration = async (userId: string, provider: string): Promise<{ config: any; secret: any } | null> => {
|
|
183
|
+
const result = await pool.query('SELECT config,encrypted_secret FROM user_integrations WHERE user_id=$1 AND provider=$2', [userId, provider]);
|
|
184
|
+
if (!result.rowCount) return null;
|
|
185
|
+
const encrypted = result.rows[0].encrypted_secret;
|
|
186
|
+
return { config: result.rows[0].config || {}, secret: encrypted ? JSON.parse(decrypt(encrypted)) : null };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const saveProviderIntegrationForUser = async (userId: string, provider: string, config: unknown, secret: unknown): Promise<void> => {
|
|
190
|
+
await pool.query(
|
|
191
|
+
`INSERT INTO user_integrations(user_id,provider,config,encrypted_secret)
|
|
192
|
+
VALUES($1,$2,$3,$4)
|
|
193
|
+
ON CONFLICT(user_id,provider) DO UPDATE SET config=excluded.config,encrypted_secret=excluded.encrypted_secret,updated_at=now()`,
|
|
194
|
+
[userId, provider, config || {}, encrypt(JSON.stringify(secret || {}))]
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const awsIamClientForUser = async (userId: string): Promise<IAMClient> => {
|
|
199
|
+
const integration = await providerIntegration(userId, 'aws');
|
|
200
|
+
if (!integration?.config?.region || !integration?.secret?.accessKeyId || !integration?.secret?.secretAccessKey) {
|
|
201
|
+
throw new Error('AWS management credentials are unavailable for this operation.');
|
|
202
|
+
}
|
|
203
|
+
const credentials = {
|
|
204
|
+
accessKeyId: String(integration.secret.accessKeyId),
|
|
205
|
+
secretAccessKey: String(integration.secret.secretAccessKey),
|
|
206
|
+
...(integration.secret.sessionToken ? { sessionToken: String(integration.secret.sessionToken) } : {})
|
|
207
|
+
};
|
|
208
|
+
return new IAMClient({ region: String(integration.config.region), credentials });
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
interface ManagedAwsRow {
|
|
212
|
+
id: string;
|
|
213
|
+
cloud_user_id: string;
|
|
214
|
+
aws_username: string;
|
|
215
|
+
access_key_id: string;
|
|
216
|
+
encrypted_secret_access_key: string;
|
|
217
|
+
indraq_role: Role;
|
|
218
|
+
created_by: string | null;
|
|
219
|
+
managed_by_user_id: string | null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const managedAwsCredentialRow = async (cloudUserId: string, awsUsername: string): Promise<ManagedAwsRow | null> => {
|
|
223
|
+
const result = await pool.query(
|
|
224
|
+
`SELECT id,cloud_user_id,aws_username,access_key_id,encrypted_secret_access_key,indraq_role,created_by,managed_by_user_id
|
|
225
|
+
FROM managed_aws_credentials
|
|
226
|
+
WHERE cloud_user_id=$1 AND aws_username=$2
|
|
227
|
+
LIMIT 1`,
|
|
228
|
+
[cloudUserId, awsUsername]
|
|
229
|
+
);
|
|
230
|
+
return (result.rows[0] as ManagedAwsRow | undefined) || null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const awsRotationOperatorUserId = async (actor: Auth, row: ManagedAwsRow): Promise<string> => {
|
|
234
|
+
if (actor.role === 'manager' || actor.role === 'admin') return actor.id;
|
|
235
|
+
const managerId = row.managed_by_user_id || row.created_by;
|
|
236
|
+
if (!managerId) throw new Error('No manager/admin AWS credential is available to rotate this access key. Ask an IndraQ manager or admin.');
|
|
237
|
+
const manager = await pool.query(
|
|
238
|
+
`SELECT u.id,u.role,u.is_active
|
|
239
|
+
FROM users u
|
|
240
|
+
JOIN user_integrations ui ON ui.user_id=u.id AND ui.provider='aws'
|
|
241
|
+
WHERE u.id=$1
|
|
242
|
+
LIMIT 1`,
|
|
243
|
+
[managerId]
|
|
244
|
+
);
|
|
245
|
+
const resolved = manager.rows[0];
|
|
246
|
+
if (!resolved?.is_active || !['manager','admin'].includes(resolved.role)) {
|
|
247
|
+
throw new Error('The manager/admin AWS credential that manages this IAM user is unavailable. Ask an IndraQ manager or admin to rotate the key.');
|
|
248
|
+
}
|
|
249
|
+
return resolved.id;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const rotateManagedAwsAccessKey = async (
|
|
253
|
+
actor: Auth,
|
|
254
|
+
targetCloudUserId: string,
|
|
255
|
+
awsUsername: string,
|
|
256
|
+
reason: string
|
|
257
|
+
): Promise<{ awsUsername: string; accessKeyId: string; secretAccessKey: string; previousAccessKeyId: string | null }> => {
|
|
258
|
+
const row = await managedAwsCredentialRow(targetCloudUserId, awsUsername);
|
|
259
|
+
if (!row) throw Object.assign(new Error('Stored AWS credentials were not found for this IndraQ user.'), { statusCode: 404 });
|
|
260
|
+
|
|
261
|
+
const operatorUserId = await awsRotationOperatorUserId(actor, row);
|
|
262
|
+
const iam = await awsIamClientForUser(operatorUserId);
|
|
263
|
+
const keys = await iam.send(new ListAccessKeysCommand({ UserName: awsUsername }));
|
|
264
|
+
const keyIds = (keys.AccessKeyMetadata || []).map((item) => item.AccessKeyId).filter((value): value is string => !!value);
|
|
265
|
+
if (keyIds.length >= 2) {
|
|
266
|
+
throw Object.assign(
|
|
267
|
+
new Error(`AWS IAM user ${awsUsername} already has two access keys. Remove the extra/unmanaged key before rotating through IndraQ.`),
|
|
268
|
+
{ statusCode: 409 }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const oldAccessKeyId = row.access_key_id || null;
|
|
273
|
+
const oldSecretAccessKey = decrypt(row.encrypted_secret_access_key);
|
|
274
|
+
const targetIntegration = await providerIntegration(targetCloudUserId, 'aws');
|
|
275
|
+
const previousProviderConfig = targetIntegration?.config || {};
|
|
276
|
+
const previousProviderSecret = targetIntegration?.secret || { accessKeyId: oldAccessKeyId, secretAccessKey: oldSecretAccessKey };
|
|
277
|
+
|
|
278
|
+
const created = await iam.send(new CreateAccessKeyCommand({ UserName: awsUsername }));
|
|
279
|
+
const newKey = created.AccessKey;
|
|
280
|
+
if (!newKey?.AccessKeyId || !newKey.SecretAccessKey) throw new Error('AWS did not return a complete replacement access key.');
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await pool.query(
|
|
284
|
+
`UPDATE managed_aws_credentials
|
|
285
|
+
SET access_key_id=$1,
|
|
286
|
+
encrypted_secret_access_key=$2,
|
|
287
|
+
managed_by_user_id=$3,
|
|
288
|
+
updated_at=now()
|
|
289
|
+
WHERE id=$4`,
|
|
290
|
+
[newKey.AccessKeyId, encrypt(newKey.SecretAccessKey), operatorUserId, row.id]
|
|
291
|
+
);
|
|
292
|
+
await saveProviderIntegrationForUser(
|
|
293
|
+
targetCloudUserId,
|
|
294
|
+
'aws',
|
|
295
|
+
previousProviderConfig,
|
|
296
|
+
{ accessKeyId: newKey.AccessKeyId, secretAccessKey: newKey.SecretAccessKey }
|
|
297
|
+
);
|
|
298
|
+
} catch (persistError) {
|
|
299
|
+
try { await iam.send(new DeleteAccessKeyCommand({ UserName: awsUsername, AccessKeyId: newKey.AccessKeyId })); } catch { /* best-effort rollback */ }
|
|
300
|
+
throw persistError;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (oldAccessKeyId && keyIds.includes(oldAccessKeyId) && oldAccessKeyId !== newKey.AccessKeyId) {
|
|
304
|
+
try {
|
|
305
|
+
await iam.send(new DeleteAccessKeyCommand({ UserName: awsUsername, AccessKeyId: oldAccessKeyId }));
|
|
306
|
+
} catch (deleteError) {
|
|
307
|
+
// Restore Cloud state to the old working credential and delete the new
|
|
308
|
+
// key when possible. This avoids silently leaving Cloud pointed at a key
|
|
309
|
+
// that was not fully rotated.
|
|
310
|
+
try {
|
|
311
|
+
await pool.query(
|
|
312
|
+
`UPDATE managed_aws_credentials
|
|
313
|
+
SET access_key_id=$1,
|
|
314
|
+
encrypted_secret_access_key=$2,
|
|
315
|
+
updated_at=now()
|
|
316
|
+
WHERE id=$3`,
|
|
317
|
+
[oldAccessKeyId, encrypt(oldSecretAccessKey), row.id]
|
|
318
|
+
);
|
|
319
|
+
await saveProviderIntegrationForUser(targetCloudUserId, 'aws', previousProviderConfig, previousProviderSecret);
|
|
320
|
+
} catch { /* best effort */ }
|
|
321
|
+
try { await iam.send(new DeleteAccessKeyCommand({ UserName: awsUsername, AccessKeyId: newKey.AccessKeyId })); } catch { /* best effort */ }
|
|
322
|
+
throw new Error(`AWS created a replacement key but the previous key could not be removed, so IndraQ attempted to roll back: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await audit(actor.id, 'aws.credentials.rotate', 'user', targetCloudUserId, reason, {
|
|
327
|
+
awsUsername,
|
|
328
|
+
previousAccessKeyId: oldAccessKeyId,
|
|
329
|
+
newAccessKeyId: newKey.AccessKeyId,
|
|
330
|
+
operatorUserId
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
awsUsername,
|
|
334
|
+
accessKeyId: newKey.AccessKeyId,
|
|
335
|
+
secretAccessKey: newKey.SecretAccessKey,
|
|
336
|
+
previousAccessKeyId: oldAccessKeyId
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const npmAdminResetPassword = async (managerUserId: string, targetIdentity: string, newPassword: string): Promise<{ config: any }> => {
|
|
341
|
+
const integration = await providerIntegration(managerUserId, 'npm');
|
|
342
|
+
if (!integration?.config?.url || !integration?.secret?.identity || !integration?.secret?.secret) throw new Error('NPM management credentials are unavailable. Ask an admin/manager with NPM configured to manage this account.');
|
|
343
|
+
const base = String(integration.config.url).replace(/\/+$/, '');
|
|
344
|
+
const tokenResponse = await fetch(`${base}/api/tokens`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identity: integration.secret.identity, secret: integration.secret.secret }) });
|
|
345
|
+
if (!tokenResponse.ok) throw new Error(`NPM management authentication failed (HTTP ${tokenResponse.status}).`);
|
|
346
|
+
const token = (await tokenResponse.json() as { token?: string }).token;
|
|
347
|
+
if (!token) throw new Error('NPM did not return a management token.');
|
|
348
|
+
const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
|
|
349
|
+
const usersResponse = await fetch(`${base}/api/users?expand=permissions`, { headers });
|
|
350
|
+
if (!usersResponse.ok) throw new Error(`Unable to list NPM users (HTTP ${usersResponse.status}).`);
|
|
351
|
+
const users = await usersResponse.json() as Array<{ id: number; email?: string; nickname?: string; name?: string }>;
|
|
352
|
+
const wanted = targetIdentity.toLowerCase();
|
|
353
|
+
const target = users.find((u) => String(u.email || '').toLowerCase() === wanted || String(u.nickname || '').toLowerCase() === wanted || String(u.name || '').toLowerCase() === wanted);
|
|
354
|
+
if (!target) throw new Error(`NPM user ${targetIdentity} was not found.`);
|
|
355
|
+
const update = await fetch(`${base}/api/users/${target.id}/auth`, { method: 'PUT', headers, body: JSON.stringify({ type: 'password', secret: newPassword }) });
|
|
356
|
+
if (!update.ok) throw new Error(`NPM password reset failed (HTTP ${update.status}).`);
|
|
357
|
+
return { config: { url: base } };
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const jenkinsRequestHeaders = async (url: string, username: string, secret: string): Promise<Record<string,string>> => {
|
|
361
|
+
const baseAuth = `Basic ${Buffer.from(`${username}:${secret}`).toString('base64')}`;
|
|
362
|
+
const headers: Record<string,string> = { authorization: baseAuth };
|
|
363
|
+
const response = await fetch(`${url.replace(/\/+$/, '')}/crumbIssuer/api/json`, { headers, redirect: 'manual' });
|
|
364
|
+
if (!response.ok) return headers;
|
|
365
|
+
const crumb = await response.json() as { crumbRequestField?: string; crumb?: string };
|
|
366
|
+
if (crumb.crumbRequestField && crumb.crumb) headers[crumb.crumbRequestField] = crumb.crumb;
|
|
367
|
+
const h = response.headers as Headers & { getSetCookie?: () => string[] };
|
|
368
|
+
const cookies = typeof h.getSetCookie === 'function' ? h.getSetCookie() : (response.headers.get('set-cookie') ? [response.headers.get('set-cookie')!] : []);
|
|
369
|
+
const cookie = cookies.map((v) => v.split(';',1)[0]?.trim()).filter(Boolean).join('; ');
|
|
370
|
+
if (cookie) headers.cookie = cookie;
|
|
371
|
+
return headers;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const jenkinsAdminResetPassword = async (managerUserId: string, provider: 'jenkins-dev'|'jenkins-prod', username: string, newPassword: string): Promise<{ config: any }> => {
|
|
375
|
+
const integration = await providerIntegration(managerUserId, provider);
|
|
376
|
+
if (!integration?.config?.url || !integration?.secret?.username || !integration?.secret?.secret) throw new Error(`Jenkins management credentials (${provider}) are unavailable.`);
|
|
377
|
+
const url = String(integration.config.url).replace(/\/+$/, '');
|
|
378
|
+
const script = `import hudson.security.HudsonPrivateSecurityRealm\ndef user = hudson.model.User.getById(${JSON.stringify(username)}, false)\nif (user == null) throw new IllegalStateException('Jenkins user not found')\nuser.addProperty(HudsonPrivateSecurityRealm.Details.fromPlainPassword(${JSON.stringify(newPassword)}))\nuser.save()\nprintln 'updated'`;
|
|
379
|
+
const response = await fetch(`${url}/scriptText`, { method: 'POST', headers: { ...(await jenkinsRequestHeaders(url, integration.secret.username, integration.secret.secret)), 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }).toString() });
|
|
380
|
+
const text = await response.text();
|
|
381
|
+
if (!response.ok || !text.includes('updated')) throw new Error(`Jenkins password reset failed (HTTP ${response.status}): ${text.trim() || response.statusText}`);
|
|
382
|
+
return { config: { url } };
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const resetManagedExternalPasswords = async (cloudUserId: string, newPassword: string, targets: string[]): Promise<Array<{ provider: string; ok: boolean; error?: string }>> => {
|
|
386
|
+
const accounts = await pool.query(
|
|
387
|
+
`SELECT mea.provider,mea.external_username,mea.external_email,mea.managed_by_user_id,mea.config
|
|
388
|
+
FROM managed_external_accounts mea
|
|
389
|
+
WHERE mea.cloud_user_id=$1
|
|
390
|
+
ORDER BY mea.provider`, [cloudUserId]
|
|
391
|
+
);
|
|
392
|
+
const results: Array<{ provider: string; ok: boolean; error?: string }> = [];
|
|
393
|
+
for (const account of accounts.rows) {
|
|
394
|
+
const family = String(account.provider).startsWith('jenkins-') ? 'jenkins' : String(account.provider);
|
|
395
|
+
if (!targets.includes(family) && !targets.includes(account.provider)) continue;
|
|
396
|
+
let managerId: string | null = account.managed_by_user_id;
|
|
397
|
+
if (!managerId) {
|
|
398
|
+
const fallback = await pool.query(`SELECT u.id FROM users u JOIN user_integrations ui ON ui.user_id=u.id AND ui.provider=$1 WHERE u.is_active=true AND u.role IN ('admin','manager') ORDER BY CASE WHEN u.role='admin' THEN 0 ELSE 1 END,u.created_at LIMIT 1`, [account.provider]);
|
|
399
|
+
managerId = fallback.rows[0]?.id || null;
|
|
400
|
+
}
|
|
401
|
+
if (!managerId) { results.push({ provider: account.provider, ok: false, error: 'No manager/admin provider credential is available.' }); continue; }
|
|
402
|
+
try {
|
|
403
|
+
if (account.provider === 'npm') {
|
|
404
|
+
const changed = await npmAdminResetPassword(managerId, account.external_email || account.external_username, newPassword);
|
|
405
|
+
await saveProviderIntegrationForUser(cloudUserId, 'npm', changed.config, { identity: account.external_email || account.external_username, secret: newPassword });
|
|
406
|
+
} else if (account.provider === 'jenkins-dev' || account.provider === 'jenkins-prod') {
|
|
407
|
+
const changed = await jenkinsAdminResetPassword(managerId, account.provider, account.external_username, newPassword);
|
|
408
|
+
await saveProviderIntegrationForUser(cloudUserId, account.provider, changed.config, { username: account.external_username, secret: newPassword });
|
|
409
|
+
}
|
|
410
|
+
results.push({ provider: account.provider, ok: true });
|
|
411
|
+
} catch (error) {
|
|
412
|
+
results.push({ provider: account.provider, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return results;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const mailTransport = () => {
|
|
419
|
+
const host = process.env.SMTP_HOST;
|
|
420
|
+
const from = process.env.SMTP_FROM;
|
|
421
|
+
if (!host || !from) throw new Error('Password reset email is not configured. Set SMTP_HOST and SMTP_FROM on IndraQ Cloud.');
|
|
422
|
+
const port = Number(process.env.SMTP_PORT || 587);
|
|
423
|
+
return {
|
|
424
|
+
transporter: nodemailer.createTransport({
|
|
425
|
+
host,
|
|
426
|
+
port,
|
|
427
|
+
secure: String(process.env.SMTP_SECURE || '').toLowerCase() === 'true',
|
|
428
|
+
...(process.env.SMTP_USER ? { auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || '' } } : {})
|
|
429
|
+
}),
|
|
430
|
+
from
|
|
431
|
+
};
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const runMigrations = async (): Promise<void> => {
|
|
435
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
436
|
+
name text PRIMARY KEY,
|
|
437
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
438
|
+
)`);
|
|
439
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
440
|
+
const sqlDir = resolve(moduleDir, '../sql');
|
|
441
|
+
const files = (await readdir(sqlDir)).filter((name) => /^\d+.*\.sql$/i.test(name)).sort();
|
|
442
|
+
for (const file of files) {
|
|
443
|
+
const alreadyApplied = await pool.query('SELECT 1 FROM schema_migrations WHERE name=$1', [file]);
|
|
444
|
+
if (alreadyApplied.rowCount) continue;
|
|
445
|
+
const sql = await readFile(resolve(sqlDir, file), 'utf8');
|
|
446
|
+
const client = await pool.connect();
|
|
447
|
+
try {
|
|
448
|
+
await client.query('BEGIN');
|
|
449
|
+
await client.query(sql);
|
|
450
|
+
await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [file]);
|
|
451
|
+
await client.query('COMMIT');
|
|
452
|
+
console.log(`Applied database migration ${file}`);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
await client.query('ROLLBACK');
|
|
455
|
+
throw error;
|
|
456
|
+
} finally {
|
|
457
|
+
client.release();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
app.get('/health', async (_req, res) => {
|
|
463
|
+
await pool.query('SELECT 1');
|
|
464
|
+
res.json({ status: 'ok' });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
app.post('/auth/login', async (req, res) => {
|
|
468
|
+
const { email, password } = req.body || {};
|
|
469
|
+
const result = await pool.query(
|
|
470
|
+
'SELECT id,email,name,password_hash,role,is_active,must_change_password FROM users WHERE lower(email)=lower($1)',
|
|
471
|
+
[email]
|
|
472
|
+
);
|
|
473
|
+
const user = result.rows[0];
|
|
474
|
+
if (!user || !user.is_active || !(await bcrypt.compare(String(password || ''), user.password_hash))) {
|
|
475
|
+
res.status(401).json({ error: 'Invalid credentials' }); return;
|
|
476
|
+
}
|
|
477
|
+
await pool.query('UPDATE users SET last_login_at=now() WHERE id=$1', [user.id]);
|
|
478
|
+
const token = jwt.sign({ id: user.id, email: user.email, role: user.role }, jwtSecret, { expiresIn: '12h' });
|
|
479
|
+
res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role, mustChangePassword: !!user.must_change_password } });
|
|
480
|
+
});
|
|
481
|
+
app.get('/auth/me', auth, (req: AuthedRequest, res) => res.json(req.auth));
|
|
482
|
+
|
|
483
|
+
app.post('/auth/first-login-password', auth, async (req: AuthedRequest, res) => {
|
|
484
|
+
const newPassword = String(req.body?.password || '');
|
|
485
|
+
if (!req.auth!.mustChangePassword) { res.status(400).json({ error: 'This account does not require a first-login password change.' }); return; }
|
|
486
|
+
if (newPassword.length < 10) { res.status(400).json({ error: 'Password must be at least 10 characters.' }); return; }
|
|
487
|
+
const external = await resetManagedExternalPasswords(req.auth!.id, newPassword, ['npm','jenkins']);
|
|
488
|
+
const failed = external.filter((item) => !item.ok);
|
|
489
|
+
if (failed.length) { res.status(502).json({ error: 'Could not rotate the temporary password on every managed provider. Retry or ask an admin.', results: external }); return; }
|
|
490
|
+
await pool.query('UPDATE users SET password_hash=$1,must_change_password=false,password_changed_at=now(),updated_at=now() WHERE id=$2', [await bcrypt.hash(newPassword, 12), req.auth!.id]);
|
|
491
|
+
await audit(req.auth!.id, 'user.password.first-login', 'user', req.auth!.id, 'Required first-login password change', { providers: external.map((item) => item.provider) });
|
|
492
|
+
res.json({ ok: true, results: [{ provider: 'cloud', ok: true }, ...external] });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
app.post('/auth/password-reset/request', async (req, res) => {
|
|
496
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
497
|
+
if (!/^\S+@\S+\.\S+$/.test(email)) { res.status(400).json({ error: 'Enter a valid email address.' }); return; }
|
|
498
|
+
const userResult = await pool.query('SELECT id,email,name,is_active FROM users WHERE lower(email)=lower($1)', [email]);
|
|
499
|
+
const user = userResult.rows[0];
|
|
500
|
+
// Always return the same response shape to avoid account enumeration.
|
|
501
|
+
if (!user || !user.is_active) { res.status(202).json({ ok: true }); return; }
|
|
502
|
+
const recent = await pool.query("SELECT 1 FROM password_reset_otps WHERE user_id=$1 AND created_at > now() - interval '60 seconds' LIMIT 1", [user.id]);
|
|
503
|
+
if (recent.rowCount) { res.status(429).json({ error: 'A reset code was sent recently. Wait one minute before requesting another.' }); return; }
|
|
504
|
+
const otp = String(randomInt(100000, 1000000));
|
|
505
|
+
await pool.query('UPDATE password_reset_otps SET used_at=now() WHERE user_id=$1 AND used_at IS NULL', [user.id]);
|
|
506
|
+
await pool.query("INSERT INTO password_reset_otps(user_id,otp_hash,expires_at) VALUES($1,$2,now()+interval '10 minutes')", [user.id, await bcrypt.hash(otp, 10)]);
|
|
507
|
+
const { transporter, from } = mailTransport();
|
|
508
|
+
await transporter.sendMail({ from, to: user.email, subject: 'IndraQ password reset code', text: `Your IndraQ password reset code is ${otp}. It expires in 10 minutes. If you did not request this, ignore this email.` });
|
|
509
|
+
res.status(202).json({ ok: true });
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
app.post('/auth/password-reset/verify', async (req, res) => {
|
|
513
|
+
const email = String(req.body?.email || '').trim().toLowerCase();
|
|
514
|
+
const otp = String(req.body?.otp || '').trim();
|
|
515
|
+
const newPassword = String(req.body?.password || '');
|
|
516
|
+
const rawTargets: string[] = Array.isArray(req.body?.targets) ? req.body.targets.map((v: unknown) => String(v).toLowerCase()) : ['cloud'];
|
|
517
|
+
const targets: string[] = [...new Set<string>(rawTargets.filter((v) => ['cloud','npm','jenkins'].includes(v)))];
|
|
518
|
+
if (!targets.length) { res.status(400).json({ error: 'Choose at least one password target: cloud, npm, or jenkins.' }); return; }
|
|
519
|
+
if (newPassword.length < 10) { res.status(400).json({ error: 'Password must be at least 10 characters.' }); return; }
|
|
520
|
+
const userResult = await pool.query('SELECT id,email,is_active FROM users WHERE lower(email)=lower($1)', [email]);
|
|
521
|
+
const user = userResult.rows[0];
|
|
522
|
+
if (!user || !user.is_active) { res.status(400).json({ error: 'Invalid or expired reset code.' }); return; }
|
|
523
|
+
const otpResult = await pool.query('SELECT id,otp_hash,expires_at,attempts,max_attempts FROM password_reset_otps WHERE user_id=$1 AND used_at IS NULL ORDER BY created_at DESC LIMIT 1', [user.id]);
|
|
524
|
+
const row = otpResult.rows[0];
|
|
525
|
+
if (!row || new Date(row.expires_at).getTime() < Date.now() || row.attempts >= row.max_attempts) { res.status(400).json({ error: 'Invalid or expired reset code.' }); return; }
|
|
526
|
+
const valid = await bcrypt.compare(otp, row.otp_hash);
|
|
527
|
+
if (!valid) { await pool.query('UPDATE password_reset_otps SET attempts=attempts+1 WHERE id=$1', [row.id]); res.status(400).json({ error: 'Invalid or expired reset code.' }); return; }
|
|
528
|
+
const results: Array<{ provider: string; ok: boolean; error?: string }> = [];
|
|
529
|
+
if (targets.includes('npm') || targets.includes('jenkins')) results.push(...await resetManagedExternalPasswords(user.id, newPassword, targets));
|
|
530
|
+
if (targets.includes('cloud')) {
|
|
531
|
+
await pool.query('UPDATE users SET password_hash=$1,must_change_password=false,password_changed_at=now(),updated_at=now() WHERE id=$2', [await bcrypt.hash(newPassword, 12), user.id]);
|
|
532
|
+
results.unshift({ provider: 'cloud', ok: true });
|
|
533
|
+
}
|
|
534
|
+
await pool.query('UPDATE password_reset_otps SET used_at=now() WHERE id=$1', [row.id]);
|
|
535
|
+
await audit(user.id, 'user.password.self-reset', 'user', user.id, 'Email OTP password reset', { targets, results: results.map((r) => ({ provider: r.provider, ok: r.ok })) });
|
|
536
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
537
|
+
res.json({ ok: results.every((r) => r.ok), results });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
app.post('/users', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
541
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
542
|
+
const { email, name, password, role = 'user' } = req.body || {};
|
|
543
|
+
if (!['user', 'manager', 'admin'].includes(role)) { res.status(400).json({ error: 'Role must be user, manager, or admin.' }); return; }
|
|
544
|
+
if (req.auth!.role === 'manager' && role !== 'user') { res.status(403).json({ error: 'Managers may only create users.' }); return; }
|
|
545
|
+
if ((role === 'manager' || role === 'admin') && req.auth!.role !== 'admin') { res.status(403).json({ error: 'Only admins may create managers or admins.' }); return; }
|
|
546
|
+
if (String(password || '').length < 10) { res.status(400).json({ error: 'Password must be at least 10 characters.' }); return; }
|
|
547
|
+
const hash = await bcrypt.hash(password, 12);
|
|
548
|
+
const result = await pool.query(
|
|
549
|
+
'INSERT INTO users(email,name,password_hash,role,created_by,must_change_password) VALUES(lower($1),$2,$3,$4,$5,true) RETURNING id,email,name,role,must_change_password,created_at',
|
|
550
|
+
[email, name, hash, role, req.auth!.id]
|
|
551
|
+
);
|
|
552
|
+
await audit(req.auth!.id, 'user.create', 'user', result.rows[0].id, reason, { role });
|
|
553
|
+
res.status(201).json(result.rows[0]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
app.patch('/users/:id/password', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
557
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
558
|
+
const id = routeParam(req.params.id);
|
|
559
|
+
const target = await pool.query('SELECT role FROM users WHERE id=$1', [id]);
|
|
560
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
561
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may only manage users.' }); return; }
|
|
562
|
+
const password = String(req.body.password || '');
|
|
563
|
+
if (password.length < 10) { res.status(400).json({ error: 'Password must be at least 10 characters.' }); return; }
|
|
564
|
+
await pool.query('UPDATE users SET password_hash=$1,must_change_password=true,updated_at=now() WHERE id=$2', [await bcrypt.hash(password, 12), id]);
|
|
565
|
+
await audit(req.auth!.id, 'user.password.update', 'user', id, reason);
|
|
566
|
+
res.status(204).end();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
app.delete('/users/:id', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
570
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
571
|
+
const id = routeParam(req.params.id);
|
|
572
|
+
const target = await pool.query('SELECT role FROM users WHERE id=$1', [id]);
|
|
573
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
574
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may only delete users.' }); return; }
|
|
575
|
+
await pool.query('UPDATE users SET is_active=false,updated_at=now() WHERE id=$1', [id]);
|
|
576
|
+
await audit(req.auth!.id, 'user.disable', 'user', id, reason);
|
|
577
|
+
res.status(204).end();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
app.get('/users', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
581
|
+
const { search, limit } = listQuery(req);
|
|
582
|
+
const result = await pool.query(
|
|
583
|
+
`SELECT id,email,name,role,is_active,must_change_password,created_at,updated_at,last_login_at
|
|
584
|
+
FROM users
|
|
585
|
+
WHERE ($1='' OR email ILIKE '%'||$1||'%' OR name ILIKE '%'||$1||'%' OR role ILIKE '%'||$1||'%')
|
|
586
|
+
ORDER BY created_at DESC
|
|
587
|
+
LIMIT $2`, [search, limit]
|
|
588
|
+
);
|
|
589
|
+
res.json(result.rows);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
app.patch('/users/:id/role', ...allow('admin'), async (req: AuthedRequest, res) => {
|
|
593
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
594
|
+
const id = routeParam(req.params.id);
|
|
595
|
+
const role = String(req.body?.role || '').trim().toLowerCase();
|
|
596
|
+
if (!['user', 'manager', 'admin'].includes(role)) { res.status(400).json({ error: 'Role must be user, manager, or admin.' }); return; }
|
|
597
|
+
if (id === req.auth!.id && role !== 'admin') { res.status(400).json({ error: 'An admin cannot demote their own active session through this endpoint.' }); return; }
|
|
598
|
+
const result = await pool.query('UPDATE users SET role=$1,updated_at=now() WHERE id=$2 RETURNING id,email,name,role,is_active,updated_at', [role, id]);
|
|
599
|
+
if (!result.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
600
|
+
await audit(req.auth!.id, 'user.role.update', 'user', id, reason, { role });
|
|
601
|
+
res.json(result.rows[0]);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
app.post('/users/:id/aws-credentials', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
605
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
606
|
+
const id = routeParam(req.params.id);
|
|
607
|
+
const target = await pool.query('SELECT id,role,is_active FROM users WHERE id=$1', [id]);
|
|
608
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
609
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may only store credentials for users.' }); return; }
|
|
610
|
+
const awsUsername = String(req.body?.awsUsername || '').trim();
|
|
611
|
+
const accessKeyId = String(req.body?.accessKeyId || '').trim();
|
|
612
|
+
const secretAccessKey = String(req.body?.secretAccessKey || '');
|
|
613
|
+
const indraqRole = String(req.body?.indraqRole || target.rows[0].role || 'user').trim().toLowerCase();
|
|
614
|
+
if (!awsUsername || !accessKeyId || !secretAccessKey) { res.status(400).json({ error: 'awsUsername, accessKeyId and secretAccessKey are required.' }); return; }
|
|
615
|
+
if (!['user','manager','admin'].includes(indraqRole)) { res.status(400).json({ error: 'indraqRole must be user, manager, or admin.' }); return; }
|
|
616
|
+
const encryptedSecret = encrypt(secretAccessKey);
|
|
617
|
+
const result = await pool.query(
|
|
618
|
+
`INSERT INTO managed_aws_credentials(cloud_user_id,aws_username,access_key_id,encrypted_secret_access_key,indraq_role,created_by,managed_by_user_id)
|
|
619
|
+
VALUES($1,$2,$3,$4,$5,$6,$6)
|
|
620
|
+
ON CONFLICT(cloud_user_id,aws_username) DO UPDATE SET
|
|
621
|
+
access_key_id=excluded.access_key_id,
|
|
622
|
+
encrypted_secret_access_key=excluded.encrypted_secret_access_key,
|
|
623
|
+
indraq_role=excluded.indraq_role,
|
|
624
|
+
managed_by_user_id=excluded.managed_by_user_id,
|
|
625
|
+
updated_at=now()
|
|
626
|
+
RETURNING id,cloud_user_id,aws_username,access_key_id,indraq_role,created_at,updated_at`,
|
|
627
|
+
[id, awsUsername, accessKeyId, encryptedSecret, indraqRole, req.auth!.id]
|
|
628
|
+
);
|
|
629
|
+
await audit(req.auth!.id, 'aws.credentials.store', 'user', id, reason, { awsUsername, accessKeyId, indraqRole });
|
|
630
|
+
res.status(201).json(result.rows[0]);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
app.delete('/users/:id/aws-credentials/:awsUsername', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
634
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
635
|
+
const id = routeParam(req.params.id);
|
|
636
|
+
const awsUsername = routeParam(req.params.awsUsername);
|
|
637
|
+
const target = await pool.query('SELECT id,role FROM users WHERE id=$1', [id]);
|
|
638
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
639
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may only remove credentials for users.' }); return; }
|
|
640
|
+
const result = await pool.query(
|
|
641
|
+
'DELETE FROM managed_aws_credentials WHERE cloud_user_id=$1 AND aws_username=$2 RETURNING access_key_id',
|
|
642
|
+
[id, awsUsername]
|
|
643
|
+
);
|
|
644
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Stored AWS credentials not found.' }); return; }
|
|
645
|
+
await audit(req.auth!.id, 'aws.credentials.delete', 'user', id, reason, { awsUsername, accessKeyId: result.rows[0].access_key_id });
|
|
646
|
+
res.status(204).end();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
app.get('/me/aws-credentials', auth, async (req: AuthedRequest, res) => {
|
|
650
|
+
const result = await pool.query(
|
|
651
|
+
'SELECT id,aws_username,access_key_id,indraq_role,created_at,updated_at FROM managed_aws_credentials WHERE cloud_user_id=$1 ORDER BY aws_username',
|
|
652
|
+
[req.auth!.id]
|
|
653
|
+
);
|
|
654
|
+
res.json(result.rows);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
app.get('/aws-credentials', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
658
|
+
const { search, limit } = listQuery(req);
|
|
659
|
+
const result = await pool.query(
|
|
660
|
+
`SELECT mac.id,mac.cloud_user_id,mac.aws_username,mac.access_key_id,mac.indraq_role,mac.created_at,mac.updated_at,
|
|
661
|
+
u.email,u.name,u.role AS user_role,u.is_active
|
|
662
|
+
FROM managed_aws_credentials mac
|
|
663
|
+
JOIN users u ON u.id=mac.cloud_user_id
|
|
664
|
+
WHERE ($1='' OR mac.aws_username ILIKE '%'||$1||'%' OR mac.access_key_id ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%' OR u.name ILIKE '%'||$1||'%')
|
|
665
|
+
ORDER BY u.email,mac.aws_username
|
|
666
|
+
LIMIT $2`,
|
|
667
|
+
[search, limit]
|
|
668
|
+
);
|
|
669
|
+
res.json(result.rows);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
app.post('/me/aws-credentials/:awsUsername/rotate', auth, async (req: AuthedRequest, res) => {
|
|
673
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
674
|
+
const awsUsername = routeParam(req.params.awsUsername);
|
|
675
|
+
const rotated = await rotateManagedAwsAccessKey(req.auth!, req.auth!.id, awsUsername, reason);
|
|
676
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
677
|
+
res.setHeader('Pragma', 'no-cache');
|
|
678
|
+
res.json(rotated);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
app.post('/users/:id/aws-credentials/:awsUsername/rotate', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
682
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
683
|
+
const id = ensureUuid(routeParam(req.params.id), 'User ID');
|
|
684
|
+
const awsUsername = routeParam(req.params.awsUsername);
|
|
685
|
+
const target = await pool.query('SELECT id,role,is_active FROM users WHERE id=$1', [id]);
|
|
686
|
+
if (!target.rowCount || !target.rows[0].is_active) { res.status(404).json({ error: 'User not found' }); return; }
|
|
687
|
+
const rotated = await rotateManagedAwsAccessKey(req.auth!, id, awsUsername, reason);
|
|
688
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
689
|
+
res.setHeader('Pragma', 'no-cache');
|
|
690
|
+
res.json(rotated);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
app.get('/users/:id/aws-credentials', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
694
|
+
const id = routeParam(req.params.id);
|
|
695
|
+
const target = await pool.query('SELECT id,role FROM users WHERE id=$1', [id]);
|
|
696
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
697
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may inspect AWS mappings only for normal users.' }); return; }
|
|
698
|
+
const result = await pool.query(
|
|
699
|
+
'SELECT id,aws_username,access_key_id,indraq_role,created_at,updated_at FROM managed_aws_credentials WHERE cloud_user_id=$1 ORDER BY aws_username',
|
|
700
|
+
[id]
|
|
701
|
+
);
|
|
702
|
+
res.json(result.rows);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
app.post('/me/aws-credentials/:awsUsername/resolve', auth, async (req: AuthedRequest, res) => {
|
|
706
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
707
|
+
const awsUsername = routeParam(req.params.awsUsername);
|
|
708
|
+
const result = await pool.query(
|
|
709
|
+
'SELECT id,aws_username,access_key_id,encrypted_secret_access_key,indraq_role FROM managed_aws_credentials WHERE cloud_user_id=$1 AND aws_username=$2',
|
|
710
|
+
[req.auth!.id, awsUsername]
|
|
711
|
+
);
|
|
712
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Stored AWS credentials not found for this IndraQ user.' }); return; }
|
|
713
|
+
await audit(req.auth!.id, 'aws.credentials.resolve', 'user', req.auth!.id, reason, { awsUsername, accessKeyId: result.rows[0].access_key_id });
|
|
714
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
715
|
+
res.setHeader('Pragma', 'no-cache');
|
|
716
|
+
res.json({ awsUsername: result.rows[0].aws_username, accessKeyId: result.rows[0].access_key_id, secretAccessKey: decrypt(result.rows[0].encrypted_secret_access_key), indraqRole: result.rows[0].indraq_role });
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
app.get('/projects', auth, async (req: AuthedRequest, res) => {
|
|
720
|
+
const { search, limit } = listQuery(req);
|
|
721
|
+
const privileged = req.auth!.role === 'manager' || req.auth!.role === 'admin';
|
|
722
|
+
const result = await pool.query(
|
|
723
|
+
`SELECT p.*,u.email AS creator_email,
|
|
724
|
+
CASE
|
|
725
|
+
WHEN $3::boolean THEN 'write'
|
|
726
|
+
WHEN p.created_by=$1 THEN 'write'
|
|
727
|
+
WHEN pm.role='owner' OR pm.access_level='write' THEN 'write'
|
|
728
|
+
ELSE 'view'
|
|
729
|
+
END AS effective_access
|
|
730
|
+
FROM projects p
|
|
731
|
+
JOIN users u ON u.id=p.created_by
|
|
732
|
+
LEFT JOIN project_members pm ON pm.project_id=p.id AND pm.user_id=$1
|
|
733
|
+
WHERE ($2='' OR p.name ILIKE '%'||$2||'%' OR p.kind ILIKE '%'||$2||'%' OR u.email ILIKE '%'||$2||'%')
|
|
734
|
+
ORDER BY p.created_at DESC
|
|
735
|
+
LIMIT $4`, [req.auth!.id, search, privileged, limit]
|
|
736
|
+
);
|
|
737
|
+
res.json(result.rows);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
app.post('/projects', auth, async (req: AuthedRequest, res) => {
|
|
741
|
+
const reason = typeof req.body?.reason === 'string' && req.body.reason.trim().length >= 3
|
|
742
|
+
? req.body.reason.trim()
|
|
743
|
+
: 'Create project from IndraQ CLI';
|
|
744
|
+
const name = String(req.body?.name || '').trim();
|
|
745
|
+
const kind = String(req.body?.kind || '').trim();
|
|
746
|
+
const config = req.body?.config || {};
|
|
747
|
+
if (!name) { res.status(400).json({ error: 'Project name is required.' }); return; }
|
|
748
|
+
const duplicate = await pool.query('SELECT id,name FROM projects WHERE lower(name)=lower($1) LIMIT 1', [name]);
|
|
749
|
+
if (duplicate.rowCount) { res.status(409).json({ error: `Project name "${name}" already exists organization-wide. Delete the old project or run \`indraq project link --search ${name}\`.` }); return; }
|
|
750
|
+
const result = await pool.query(
|
|
751
|
+
'INSERT INTO projects(name,kind,config,created_by) VALUES($1,$2,$3,$4) RETURNING *',
|
|
752
|
+
[name, kind, config, req.auth!.id]
|
|
753
|
+
);
|
|
754
|
+
await pool.query(
|
|
755
|
+
"INSERT INTO project_members(project_id,user_id,role,access_level,added_by) VALUES($1,$2,'owner','write',$3) ON CONFLICT(project_id,user_id) DO NOTHING",
|
|
756
|
+
[result.rows[0].id, req.auth!.id, req.auth!.id]
|
|
757
|
+
);
|
|
758
|
+
await audit(req.auth!.id, 'project.create', 'project', result.rows[0].id, reason);
|
|
759
|
+
res.status(201).json({ ...result.rows[0], effective_access: 'write' });
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
app.get('/projects/:id', auth, async (req: AuthedRequest, res) => {
|
|
763
|
+
const id = ensureUuid(routeParam(req.params.id), 'Project ID');
|
|
764
|
+
const access = await projectAccessLevel(id, req.auth!);
|
|
765
|
+
if (!access) { res.status(404).json({ error: 'Project not found' }); return; }
|
|
766
|
+
const result = await pool.query('SELECT p.*,u.email AS creator_email FROM projects p JOIN users u ON u.id=p.created_by WHERE p.id=$1', [id]);
|
|
767
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Project not found' }); return; }
|
|
768
|
+
res.json({ ...result.rows[0], effective_access: access });
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
app.patch('/projects/:id', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
772
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
773
|
+
const id = ensureUuid(routeParam(req.params.id), 'Project ID');
|
|
774
|
+
if (typeof req.body?.name === 'string' && req.body.name.trim()) {
|
|
775
|
+
const duplicate = await pool.query('SELECT id FROM projects WHERE lower(name)=lower($1) AND id<>$2 LIMIT 1', [req.body.name.trim(), id]);
|
|
776
|
+
if (duplicate.rowCount) { res.status(409).json({ error: `Project name "${req.body.name.trim()}" already exists organization-wide.` }); return; }
|
|
777
|
+
}
|
|
778
|
+
const result = await pool.query(
|
|
779
|
+
'UPDATE projects SET config=COALESCE($1,config),name=COALESCE($2,name),updated_at=now() WHERE id=$3 RETURNING *',
|
|
780
|
+
[req.body.config || null, req.body.name || null, id]
|
|
781
|
+
);
|
|
782
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Project not found' }); return; }
|
|
783
|
+
await audit(req.auth!.id, 'project.update', 'project', id, reason);
|
|
784
|
+
res.json(result.rows[0]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
app.delete('/projects/:id', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
788
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
789
|
+
const id = routeParam(req.params.id);
|
|
790
|
+
const result = await pool.query('DELETE FROM projects WHERE id=$1 RETURNING id,name', [id]);
|
|
791
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Project not found' }); return; }
|
|
792
|
+
await audit(req.auth!.id, 'project.delete', 'project', id, reason, { name: result.rows[0].name });
|
|
793
|
+
res.status(204).end();
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
app.get('/projects/:id/members', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
797
|
+
const id = routeParam(req.params.id);
|
|
798
|
+
const result = await pool.query(`SELECT pm.project_id,pm.user_id,pm.role,pm.access_level,pm.added_at,u.email,u.name,u.role AS user_role
|
|
799
|
+
FROM project_members pm JOIN users u ON u.id=pm.user_id
|
|
800
|
+
WHERE pm.project_id=$1 AND u.is_active=true ORDER BY u.email`, [id]);
|
|
801
|
+
res.json(result.rows);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
app.post('/projects/:id/members', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
805
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
806
|
+
const id = routeParam(req.params.id);
|
|
807
|
+
const userId = String(req.body?.userId || '');
|
|
808
|
+
const project = await pool.query('SELECT id FROM projects WHERE id=$1', [id]);
|
|
809
|
+
if (!project.rowCount) { res.status(404).json({ error: 'Project not found' }); return; }
|
|
810
|
+
const target = await pool.query('SELECT id,email,role,is_active FROM users WHERE id=$1', [userId]);
|
|
811
|
+
if (!target.rowCount || !target.rows[0].is_active) { res.status(404).json({ error: 'User not found' }); return; }
|
|
812
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may add only normal users to projects.' }); return; }
|
|
813
|
+
const accessLevel = req.body?.accessLevel === 'view' ? 'view' : 'write';
|
|
814
|
+
await pool.query(`INSERT INTO project_members(project_id,user_id,role,access_level,added_by)
|
|
815
|
+
VALUES($1,$2,'user',$3,$4)
|
|
816
|
+
ON CONFLICT(project_id,user_id) DO UPDATE SET access_level=excluded.access_level,added_by=excluded.added_by,added_at=now()`, [id, userId, accessLevel, req.auth!.id]);
|
|
817
|
+
await audit(req.auth!.id, 'project.member.add', 'project', id, reason, { userId, email: target.rows[0].email, accessLevel });
|
|
818
|
+
res.status(201).json({ projectId: id, userId, email: target.rows[0].email, accessLevel });
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
app.delete('/projects/:id/members/:userId', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
822
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
823
|
+
const id = routeParam(req.params.id);
|
|
824
|
+
const userId = routeParam(req.params.userId);
|
|
825
|
+
const target = await pool.query('SELECT email,role FROM users WHERE id=$1', [userId]);
|
|
826
|
+
if (req.auth!.role === 'manager' && target.rows[0]?.role && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may remove only normal users from projects.' }); return; }
|
|
827
|
+
const result = await pool.query("DELETE FROM project_members WHERE project_id=$1 AND user_id=$2 AND role<>'owner' RETURNING user_id", [id, userId]);
|
|
828
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Project membership not found or owner membership cannot be removed.' }); return; }
|
|
829
|
+
await pool.query(`DELETE FROM project_resource_grants g USING project_resources pr
|
|
830
|
+
WHERE g.project_resource_id=pr.id AND pr.project_id=$1 AND g.user_id=$2`, [id, userId]);
|
|
831
|
+
await audit(req.auth!.id, 'project.member.remove', 'project', id, reason, { userId, email: target.rows[0]?.email });
|
|
832
|
+
res.status(204).end();
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Shared deployment environments are generic admin-managed named server targets.
|
|
836
|
+
// They do not carry PRIMARY/SECONDARY semantics. Failover roles are assigned only
|
|
837
|
+
// when a user creates/reconciles a Route53 FAILOVER record.
|
|
838
|
+
app.get('/environments', auth, async (req, res) => {
|
|
839
|
+
const { search, limit } = listQuery(req);
|
|
840
|
+
const result = await pool.query(`
|
|
841
|
+
SELECT id,name,description,host(ipv4) ipv4,region,health_path AS "healthPath",created_at,updated_at
|
|
842
|
+
FROM environments
|
|
843
|
+
WHERE ($1='' OR name ILIKE '%'||$1||'%' OR coalesce(description,'') ILIKE '%'||$1||'%' OR host(ipv4) ILIKE '%'||$1||'%')
|
|
844
|
+
ORDER BY name
|
|
845
|
+
LIMIT $2
|
|
846
|
+
`, [search, limit]);
|
|
847
|
+
res.json(result.rows);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
app.post('/environments', ...allow('admin'), async (req: AuthedRequest, res) => {
|
|
851
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
852
|
+
const name = String(req.body?.name || '').trim().toLowerCase();
|
|
853
|
+
const ipv4 = String(req.body?.ipv4 || '').trim();
|
|
854
|
+
const description = req.body?.description ? String(req.body.description).trim() : null;
|
|
855
|
+
const region = req.body?.region ? String(req.body.region).trim() : null;
|
|
856
|
+
const rawHealthPath = String(req.body?.healthPath || '/health').trim() || '/health';
|
|
857
|
+
const healthPath = rawHealthPath.startsWith('/') ? rawHealthPath : `/${rawHealthPath}`;
|
|
858
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name)) { res.status(400).json({ error: 'Environment name may contain letters, numbers, dots, hyphens and underscores.' }); return; }
|
|
859
|
+
if (isIP(ipv4) !== 4) { res.status(400).json({ error: 'Environment IPv4 must be a valid IPv4 address.' }); return; }
|
|
860
|
+
const result = await pool.query(
|
|
861
|
+
'INSERT INTO environments(name,description,ipv4,region,health_path,created_by) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,name,description,host(ipv4) ipv4,region,health_path AS "healthPath",created_at,updated_at',
|
|
862
|
+
[name, description, ipv4, region, healthPath, req.auth!.id]
|
|
863
|
+
);
|
|
864
|
+
await audit(req.auth!.id, 'environment.create', 'environment', result.rows[0].id, reason, { name, ipv4, region });
|
|
865
|
+
res.status(201).json(result.rows[0]);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
app.put('/environments/:id', ...allow('admin'), async (req: AuthedRequest, res) => {
|
|
869
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
870
|
+
const id = routeParam(req.params.id);
|
|
871
|
+
const name = String(req.body?.name || '').trim().toLowerCase();
|
|
872
|
+
const ipv4 = String(req.body?.ipv4 || '').trim();
|
|
873
|
+
const description = req.body?.description ? String(req.body.description).trim() : null;
|
|
874
|
+
const region = req.body?.region ? String(req.body.region).trim() : null;
|
|
875
|
+
const rawHealthPath = String(req.body?.healthPath || '/health').trim() || '/health';
|
|
876
|
+
const healthPath = rawHealthPath.startsWith('/') ? rawHealthPath : `/${rawHealthPath}`;
|
|
877
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name)) { res.status(400).json({ error: 'Invalid environment name.' }); return; }
|
|
878
|
+
if (isIP(ipv4) !== 4) { res.status(400).json({ error: 'Environment IPv4 must be a valid IPv4 address.' }); return; }
|
|
879
|
+
const result = await pool.query(
|
|
880
|
+
'UPDATE environments SET name=$1,description=$2,ipv4=$3,region=$4,health_path=$5,updated_at=now() WHERE id=$6 RETURNING id,name,description,host(ipv4) ipv4,region,health_path AS "healthPath",created_at,updated_at',
|
|
881
|
+
[name, description, ipv4, region, healthPath, id]
|
|
882
|
+
);
|
|
883
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Environment not found' }); return; }
|
|
884
|
+
await audit(req.auth!.id, 'environment.update', 'environment', id, reason, { name, ipv4, region });
|
|
885
|
+
res.json(result.rows[0]);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
app.delete('/environments/:id', ...allow('admin'), async (req: AuthedRequest, res) => {
|
|
889
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
890
|
+
const id = routeParam(req.params.id);
|
|
891
|
+
const result = await pool.query('DELETE FROM environments WHERE id=$1 RETURNING id,name', [id]);
|
|
892
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Environment not found' }); return; }
|
|
893
|
+
await audit(req.auth!.id, 'environment.delete', 'environment', id, reason, { name: result.rows[0].name });
|
|
894
|
+
res.status(204).end();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
app.get('/servers', auth, async (_req, res) => {
|
|
898
|
+
res.json((await pool.query('SELECT id,name,environment,host(ipv4) ipv4,health_path,is_backup,created_at FROM servers ORDER BY environment,name')).rows);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
app.post('/servers', ...allow('admin'), async (req: AuthedRequest, res) => {
|
|
902
|
+
const { name, environment, ipv4, healthPath = '/health', isBackup = false } = req.body || {};
|
|
903
|
+
const result = await pool.query(
|
|
904
|
+
'INSERT INTO servers(name,environment,ipv4,health_path,is_backup,created_by) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,name,environment,host(ipv4) ipv4,health_path,is_backup',
|
|
905
|
+
[name, environment, ipv4, healthPath, isBackup, req.auth!.id]
|
|
906
|
+
);
|
|
907
|
+
await audit(req.auth!.id, 'server.create', 'server', result.rows[0].id, req.body.reason);
|
|
908
|
+
res.status(201).json(result.rows[0]);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// Reusable provider credentials are scoped to the authenticated IndraQ user.
|
|
912
|
+
// A user configures AWS/NPM/Jenkins/GHCR once and can reuse that profile across
|
|
913
|
+
// every project they are allowed to access. Secrets are encrypted at rest and
|
|
914
|
+
// every resolve is audited. No user can read another user's provider secret.
|
|
915
|
+
app.get('/me/integrations', auth, async (req: AuthedRequest, res) => {
|
|
916
|
+
const result = await pool.query(
|
|
917
|
+
'SELECT id,provider,config,(encrypted_secret IS NOT NULL) has_secret,updated_at FROM user_integrations WHERE user_id=$1 ORDER BY provider',
|
|
918
|
+
[req.auth!.id]
|
|
919
|
+
);
|
|
920
|
+
res.json(result.rows);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// Managers/admins may provision a provider profile for a target user. This is
|
|
924
|
+
// used by unified user creation so generated AWS credentials are immediately
|
|
925
|
+
// usable by that user's IndraQ CLI without a second manual provider setup.
|
|
926
|
+
app.put('/users/:id/integrations/:provider', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
927
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
928
|
+
const userId = routeParam(req.params.id);
|
|
929
|
+
const target = await pool.query('SELECT id,email,role,is_active FROM users WHERE id=$1', [userId]);
|
|
930
|
+
if (!target.rowCount || !target.rows[0].is_active) { res.status(404).json({ error: 'User not found' }); return; }
|
|
931
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may provision providers only for normal users.' }); return; }
|
|
932
|
+
let provider: string;
|
|
933
|
+
try { provider = providerName(routeParam(req.params.provider)); }
|
|
934
|
+
catch { res.status(400).json({ error: 'Invalid provider name' }); return; }
|
|
935
|
+
const secret = req.body.secret ? encrypt(JSON.stringify(req.body.secret)) : null;
|
|
936
|
+
const result = await pool.query(
|
|
937
|
+
`INSERT INTO user_integrations(user_id,provider,config,encrypted_secret)
|
|
938
|
+
VALUES($1,$2,$3,$4)
|
|
939
|
+
ON CONFLICT(user_id,provider) DO UPDATE SET
|
|
940
|
+
config=excluded.config,
|
|
941
|
+
encrypted_secret=COALESCE(excluded.encrypted_secret,user_integrations.encrypted_secret),
|
|
942
|
+
updated_at=now()
|
|
943
|
+
RETURNING id,user_id,provider,config,(encrypted_secret IS NOT NULL) has_secret,updated_at`,
|
|
944
|
+
[userId, provider, req.body.config || {}, secret]
|
|
945
|
+
);
|
|
946
|
+
await audit(req.auth!.id, 'user.integration.provision', 'user-integration', result.rows[0].id, reason, { userId, provider, email: target.rows[0].email });
|
|
947
|
+
res.json(result.rows[0]);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
app.get('/me/external-accounts', auth, async (req: AuthedRequest, res) => {
|
|
951
|
+
const result = await pool.query('SELECT provider,external_username,external_email,config,updated_at FROM managed_external_accounts WHERE cloud_user_id=$1 ORDER BY provider', [req.auth!.id]);
|
|
952
|
+
res.json(result.rows);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
app.put('/users/:id/external-accounts/:provider', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
956
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
957
|
+
const userId = ensureUuid(routeParam(req.params.id), 'User ID');
|
|
958
|
+
const provider = providerName(routeParam(req.params.provider));
|
|
959
|
+
if (!['npm','jenkins-dev','jenkins-prod'].includes(provider)) { res.status(400).json({ error: 'Managed external provider must be npm, jenkins-dev, or jenkins-prod.' }); return; }
|
|
960
|
+
const target = await pool.query('SELECT id,email,role,is_active FROM users WHERE id=$1', [userId]);
|
|
961
|
+
if (!target.rowCount || !target.rows[0].is_active) { res.status(404).json({ error: 'User not found' }); return; }
|
|
962
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may manage external identities only for normal users.' }); return; }
|
|
963
|
+
const externalUsername = String(req.body?.externalUsername || '').trim();
|
|
964
|
+
const externalEmail = req.body?.externalEmail ? String(req.body.externalEmail).trim() : null;
|
|
965
|
+
if (!externalUsername) { res.status(400).json({ error: 'externalUsername is required.' }); return; }
|
|
966
|
+
const result = await pool.query(
|
|
967
|
+
`INSERT INTO managed_external_accounts(cloud_user_id,provider,external_username,external_email,managed_by_user_id,config)
|
|
968
|
+
VALUES($1,$2,$3,$4,$5,$6)
|
|
969
|
+
ON CONFLICT(cloud_user_id,provider) DO UPDATE SET external_username=excluded.external_username,external_email=excluded.external_email,managed_by_user_id=excluded.managed_by_user_id,config=excluded.config,updated_at=now()
|
|
970
|
+
RETURNING id,cloud_user_id,provider,external_username,external_email,managed_by_user_id,config,updated_at`,
|
|
971
|
+
[userId, provider, externalUsername, externalEmail, req.auth!.id, req.body?.config || {}]
|
|
972
|
+
);
|
|
973
|
+
await audit(req.auth!.id, 'user.external-account.upsert', 'user', userId, reason, { provider, externalUsername, externalEmail });
|
|
974
|
+
res.json(result.rows[0]);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
app.delete('/users/:id/external-accounts/:provider', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
978
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
979
|
+
const userId = ensureUuid(routeParam(req.params.id), 'User ID');
|
|
980
|
+
const provider = providerName(routeParam(req.params.provider));
|
|
981
|
+
const target = await pool.query('SELECT role FROM users WHERE id=$1', [userId]);
|
|
982
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
983
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may manage external identities only for normal users.' }); return; }
|
|
984
|
+
await pool.query('DELETE FROM managed_external_accounts WHERE cloud_user_id=$1 AND provider=$2', [userId, provider]);
|
|
985
|
+
await pool.query('DELETE FROM user_integrations WHERE user_id=$1 AND provider=$2', [userId, provider]);
|
|
986
|
+
await audit(req.auth!.id, 'user.external-account.delete', 'user', userId, reason, { provider });
|
|
987
|
+
res.status(204).end();
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
app.put('/me/integrations/:provider', auth, async (req: AuthedRequest, res) => {
|
|
991
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
992
|
+
let provider: string;
|
|
993
|
+
try { provider = providerName(routeParam(req.params.provider)); }
|
|
994
|
+
catch { res.status(400).json({ error: 'Invalid provider name' }); return; }
|
|
995
|
+
const secret = req.body.secret ? encrypt(JSON.stringify(req.body.secret)) : null;
|
|
996
|
+
const result = await pool.query(
|
|
997
|
+
`INSERT INTO user_integrations(user_id,provider,config,encrypted_secret)
|
|
998
|
+
VALUES($1,$2,$3,$4)
|
|
999
|
+
ON CONFLICT(user_id,provider) DO UPDATE SET
|
|
1000
|
+
config=excluded.config,
|
|
1001
|
+
encrypted_secret=COALESCE(excluded.encrypted_secret,user_integrations.encrypted_secret),
|
|
1002
|
+
updated_at=now()
|
|
1003
|
+
RETURNING id,provider,config,(encrypted_secret IS NOT NULL) has_secret,updated_at`,
|
|
1004
|
+
[req.auth!.id, provider, req.body.config || {}, secret]
|
|
1005
|
+
);
|
|
1006
|
+
await audit(req.auth!.id, 'user.integration.upsert', 'user-integration', result.rows[0].id, reason, { provider, hasSecret: !!secret });
|
|
1007
|
+
res.json(result.rows[0]);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
app.post('/me/integrations/:provider/resolve', auth, async (req: AuthedRequest, res) => {
|
|
1011
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1012
|
+
const projectId = req.body.projectId ? String(req.body.projectId) : '';
|
|
1013
|
+
if (projectId && !(await hasProjectAccess(projectId, req.auth!))) {
|
|
1014
|
+
res.status(404).json({ error: 'Project not found or access denied' }); return;
|
|
1015
|
+
}
|
|
1016
|
+
let provider: string;
|
|
1017
|
+
try { provider = providerName(routeParam(req.params.provider)); }
|
|
1018
|
+
catch { res.status(400).json({ error: 'Invalid provider name' }); return; }
|
|
1019
|
+
const result = await pool.query(
|
|
1020
|
+
'SELECT id,config,encrypted_secret FROM user_integrations WHERE user_id=$1 AND provider=$2',
|
|
1021
|
+
[req.auth!.id, provider]
|
|
1022
|
+
);
|
|
1023
|
+
if (!result.rowCount) { res.status(404).json({ error: `Provider ${provider} is not configured for the current user` }); return; }
|
|
1024
|
+
const encrypted = result.rows[0].encrypted_secret;
|
|
1025
|
+
const secret = encrypted ? JSON.parse(decrypt(encrypted)) : null;
|
|
1026
|
+
await audit(req.auth!.id, 'user.integration.resolve', projectId ? 'project' : 'user-integration', projectId || result.rows[0].id, reason, { provider });
|
|
1027
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1028
|
+
res.setHeader('Pragma', 'no-cache');
|
|
1029
|
+
res.json({ config: result.rows[0].config || {}, secret });
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
app.get('/projects/:projectId/resources', auth, async (req: AuthedRequest, res) => {
|
|
1033
|
+
const projectId = routeParam(req.params.projectId);
|
|
1034
|
+
if (!(await hasProjectAccess(projectId, req.auth!))) { res.status(404).json({ error: 'Project not found or access denied' }); return; }
|
|
1035
|
+
const env = typeof req.query.environment === 'string' ? req.query.environment : undefined;
|
|
1036
|
+
const params: unknown[] = [projectId];
|
|
1037
|
+
let where = 'pr.project_id=$1';
|
|
1038
|
+
if (env) { params.push(environmentName(env)); where += ` AND pr.environment=$${params.length}`; }
|
|
1039
|
+
const result = await pool.query(
|
|
1040
|
+
`SELECT pr.id,pr.project_id,pr.environment,pr.environment_id,pr.resource_type,pr.provider,pr.provider_user_id,pr.external_id,pr.config,pr.created_by,pr.created_at,pr.updated_at
|
|
1041
|
+
FROM project_resources pr WHERE ${where} ORDER BY pr.environment,pr.resource_type`,
|
|
1042
|
+
params
|
|
1043
|
+
);
|
|
1044
|
+
res.json(result.rows);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
app.get('/projects/:projectId/resources/:type', auth, async (req: AuthedRequest, res) => {
|
|
1048
|
+
const projectId = routeParam(req.params.projectId);
|
|
1049
|
+
if (!(await hasProjectAccess(projectId, req.auth!))) { res.status(404).json({ error: 'Project not found or access denied' }); return; }
|
|
1050
|
+
let type: string;
|
|
1051
|
+
try { type = resourceType(routeParam(req.params.type)); }
|
|
1052
|
+
catch { res.status(400).json({ error: 'Invalid resource type' }); return; }
|
|
1053
|
+
const environment = environmentName(req.query.environment || 'dev');
|
|
1054
|
+
const result = await pool.query(
|
|
1055
|
+
'SELECT id,project_id,environment,environment_id,resource_type,provider,provider_user_id,external_id,config,created_by,created_at,updated_at FROM project_resources WHERE project_id=$1 AND environment=$2 AND resource_type=$3',
|
|
1056
|
+
[projectId, environment, type]
|
|
1057
|
+
);
|
|
1058
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Project resource not configured' }); return; }
|
|
1059
|
+
if (!(await hasProjectResourceAccess(result.rows[0].id, req.auth!))) { res.status(404).json({ error: 'Project resource not configured or access denied' }); return; }
|
|
1060
|
+
res.json(result.rows[0]);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
app.put('/projects/:projectId/resources/:type', auth, async (req: AuthedRequest, res) => {
|
|
1064
|
+
const projectId = routeParam(req.params.projectId);
|
|
1065
|
+
if (!(await hasProjectWriteAccess(projectId, req.auth!))) { res.status(403).json({ error: 'WRITE access is required to change project infrastructure. Ask a manager/admin to grant WRITE access.' }); return; }
|
|
1066
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1067
|
+
let type: string;
|
|
1068
|
+
try { type = resourceType(routeParam(req.params.type)); }
|
|
1069
|
+
catch { res.status(400).json({ error: 'Invalid resource type' }); return; }
|
|
1070
|
+
const provider = providerName(String(req.body.provider || type));
|
|
1071
|
+
const environment = environmentName(req.body.environment || 'dev');
|
|
1072
|
+
const existing = await pool.query('SELECT id FROM project_resources WHERE project_id=$1 AND environment=$2 AND resource_type=$3', [projectId, environment, type]);
|
|
1073
|
+
if (existing.rowCount && !(await hasProjectResourceAccess(existing.rows[0].id, req.auth!, true))) {
|
|
1074
|
+
res.status(403).json({ error: 'You are not allowed to update this project resource. Ask a manager/admin to grant access.' }); return;
|
|
1075
|
+
}
|
|
1076
|
+
const externalId = req.body.externalId ? String(req.body.externalId) : null;
|
|
1077
|
+
const fullImage = provider === 'ghcr' && req.body?.config?.fullImage ? String(req.body.config.fullImage).trim().toLowerCase() : '';
|
|
1078
|
+
if (fullImage) {
|
|
1079
|
+
const claimed = await pool.query(`SELECT project_id FROM project_resources WHERE provider='ghcr' AND lower(config->>'fullImage')=$1 AND NOT (project_id=$2 AND environment=$3 AND resource_type=$4) LIMIT 1`, [fullImage, projectId, environment, type]);
|
|
1080
|
+
if (claimed.rowCount) { res.status(409).json({ error: `GHCR image ${fullImage} is already associated with another IndraQ project. Choose a unique image name.` }); return; }
|
|
1081
|
+
}
|
|
1082
|
+
const result = await pool.query(
|
|
1083
|
+
`INSERT INTO project_resources(project_id,environment,environment_id,resource_type,provider,provider_user_id,external_id,config,created_by,updated_by)
|
|
1084
|
+
VALUES($1,$2,$8,$3,$4,$5,$6,$7,$5,$5)
|
|
1085
|
+
ON CONFLICT(project_id,environment,resource_type) DO UPDATE SET
|
|
1086
|
+
provider=excluded.provider,
|
|
1087
|
+
environment_id=excluded.environment_id,
|
|
1088
|
+
provider_user_id=excluded.provider_user_id,
|
|
1089
|
+
external_id=excluded.external_id,
|
|
1090
|
+
config=excluded.config,
|
|
1091
|
+
updated_by=excluded.updated_by,
|
|
1092
|
+
updated_at=now()
|
|
1093
|
+
RETURNING id,project_id,environment,resource_type,provider,provider_user_id,external_id,config,created_by,created_at,updated_at`,
|
|
1094
|
+
[projectId, environment, type, provider, req.auth!.id, externalId, req.body.config || {}, req.body.environmentId || req.body.config?.environmentId || null]
|
|
1095
|
+
);
|
|
1096
|
+
await pool.query(`INSERT INTO project_resource_grants(project_resource_id,user_id,permission,created_by)
|
|
1097
|
+
VALUES($1,$2,'manage',$2)
|
|
1098
|
+
ON CONFLICT(project_resource_id,user_id) DO UPDATE SET permission='manage'`, [result.rows[0].id, req.auth!.id]);
|
|
1099
|
+
await audit(req.auth!.id, 'project.resource.upsert', 'project-resource', result.rows[0].id, reason, { projectId, type, provider, environment, externalId });
|
|
1100
|
+
res.json(result.rows[0]);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
app.get('/users/:id/resource-grants', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
1104
|
+
const userId = routeParam(req.params.id);
|
|
1105
|
+
const target = await pool.query('SELECT id,email,role,is_active FROM users WHERE id=$1', [userId]);
|
|
1106
|
+
if (!target.rowCount) { res.status(404).json({ error: 'User not found' }); return; }
|
|
1107
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may inspect grants only for normal users.' }); return; }
|
|
1108
|
+
const result = await pool.query(`SELECT g.id,g.project_resource_id,g.user_id,g.permission,g.created_at,
|
|
1109
|
+
pr.project_id,p.name AS project_name,pr.resource_type,pr.provider,pr.environment,pr.external_id,pr.config
|
|
1110
|
+
FROM project_resource_grants g
|
|
1111
|
+
JOIN project_resources pr ON pr.id=g.project_resource_id
|
|
1112
|
+
JOIN projects p ON p.id=pr.project_id
|
|
1113
|
+
WHERE g.user_id=$1
|
|
1114
|
+
ORDER BY p.name,pr.provider,pr.resource_type`, [userId]);
|
|
1115
|
+
res.json(result.rows);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
app.get('/projects/:projectId/resource-grants', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
1119
|
+
const projectId = routeParam(req.params.projectId);
|
|
1120
|
+
const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined;
|
|
1121
|
+
const result = await pool.query(`SELECT g.id,g.project_resource_id,g.user_id,g.permission,g.created_at,
|
|
1122
|
+
u.email,u.name,pr.resource_type,pr.provider,pr.environment,pr.external_id,pr.config
|
|
1123
|
+
FROM project_resource_grants g
|
|
1124
|
+
JOIN project_resources pr ON pr.id=g.project_resource_id
|
|
1125
|
+
JOIN users u ON u.id=g.user_id
|
|
1126
|
+
WHERE pr.project_id=$1 ${userId ? 'AND g.user_id=$2' : ''}
|
|
1127
|
+
ORDER BY u.email,pr.provider,pr.resource_type`, userId ? [projectId, userId] : [projectId]);
|
|
1128
|
+
res.json(result.rows);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
app.post('/projects/:projectId/resources/:resourceId/grants', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
1132
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1133
|
+
const projectId = routeParam(req.params.projectId);
|
|
1134
|
+
const resourceId = routeParam(req.params.resourceId);
|
|
1135
|
+
const userId = String(req.body?.userId || '');
|
|
1136
|
+
const permission = req.body?.permission === 'view' ? 'view' : 'manage';
|
|
1137
|
+
const resource = await pool.query('SELECT id,provider,resource_type,external_id,config FROM project_resources WHERE id=$1 AND project_id=$2', [resourceId, projectId]);
|
|
1138
|
+
if (!resource.rowCount) { res.status(404).json({ error: 'Project resource not found' }); return; }
|
|
1139
|
+
const target = await pool.query('SELECT id,email,role,is_active FROM users WHERE id=$1', [userId]);
|
|
1140
|
+
if (!target.rowCount || !target.rows[0].is_active) { res.status(404).json({ error: 'User not found' }); return; }
|
|
1141
|
+
if (req.auth!.role === 'manager' && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may grant resources only to normal users.' }); return; }
|
|
1142
|
+
await pool.query(`INSERT INTO project_resource_grants(project_resource_id,user_id,permission,created_by)
|
|
1143
|
+
VALUES($1,$2,$3,$4)
|
|
1144
|
+
ON CONFLICT(project_resource_id,user_id) DO UPDATE SET permission=excluded.permission,created_by=excluded.created_by,created_at=now()`, [resourceId, userId, permission, req.auth!.id]);
|
|
1145
|
+
await audit(req.auth!.id, 'project.resource.grant', 'project-resource', resourceId, reason, { projectId, userId, email: target.rows[0].email, permission, provider: resource.rows[0].provider });
|
|
1146
|
+
res.status(201).json({ resourceId, userId, permission, resource: resource.rows[0] });
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
app.delete('/projects/:projectId/resources/:resourceId/grants/:userId', ...allow('manager', 'admin'), async (req: AuthedRequest, res) => {
|
|
1150
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1151
|
+
const projectId = routeParam(req.params.projectId);
|
|
1152
|
+
const resourceId = routeParam(req.params.resourceId);
|
|
1153
|
+
const userId = routeParam(req.params.userId);
|
|
1154
|
+
const target = await pool.query('SELECT email,role FROM users WHERE id=$1', [userId]);
|
|
1155
|
+
if (req.auth!.role === 'manager' && target.rows[0]?.role && target.rows[0].role !== 'user') { res.status(403).json({ error: 'Managers may revoke resources only from normal users.' }); return; }
|
|
1156
|
+
const result = await pool.query(`DELETE FROM project_resource_grants g
|
|
1157
|
+
USING project_resources pr
|
|
1158
|
+
WHERE g.project_resource_id=pr.id AND pr.project_id=$1 AND g.project_resource_id=$2 AND g.user_id=$3
|
|
1159
|
+
RETURNING g.id`, [projectId, resourceId, userId]);
|
|
1160
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Resource grant not found' }); return; }
|
|
1161
|
+
await audit(req.auth!.id, 'project.resource.revoke', 'project-resource', resourceId, reason, { projectId, userId, email: target.rows[0]?.email });
|
|
1162
|
+
res.status(204).end();
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
// Legacy v1.6 project-level provider credential endpoints are retained so old clients do not break.
|
|
1166
|
+
app.put('/projects/:projectId/integrations/:provider', auth, async (req: AuthedRequest, res) => {
|
|
1167
|
+
const projectId = routeParam(req.params.projectId);
|
|
1168
|
+
if (!(await hasProjectAccess(projectId, req.auth!))) { res.status(404).json({ error: 'Project not found or access denied' }); return; }
|
|
1169
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1170
|
+
let provider: string;
|
|
1171
|
+
try { provider = providerName(routeParam(req.params.provider)); }
|
|
1172
|
+
catch { res.status(400).json({ error: 'Invalid provider name' }); return; }
|
|
1173
|
+
const secret = req.body.secret ? encrypt(JSON.stringify(req.body.secret)) : null;
|
|
1174
|
+
const result = await pool.query(
|
|
1175
|
+
`INSERT INTO project_integrations(project_id,provider,config,encrypted_secret) VALUES($1,$2,$3,$4)
|
|
1176
|
+
ON CONFLICT(project_id,provider) DO UPDATE SET config=excluded.config, encrypted_secret=COALESCE(excluded.encrypted_secret,project_integrations.encrypted_secret)
|
|
1177
|
+
RETURNING id,project_id,provider,config,(encrypted_secret IS NOT NULL) has_secret`,
|
|
1178
|
+
[projectId, provider, req.body.config || {}, secret]
|
|
1179
|
+
);
|
|
1180
|
+
await audit(req.auth!.id, 'integration.upsert.legacy', 'project', projectId, reason, { provider, hasSecret: !!secret });
|
|
1181
|
+
res.json(result.rows[0]);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
app.get('/projects/:projectId/integrations', auth, async (req: AuthedRequest, res) => {
|
|
1185
|
+
const projectId = routeParam(req.params.projectId);
|
|
1186
|
+
if (!(await hasProjectAccess(projectId, req.auth!))) { res.status(404).json({ error: 'Project not found or access denied' }); return; }
|
|
1187
|
+
const result = await pool.query(
|
|
1188
|
+
'SELECT id,project_id,provider,config,(encrypted_secret IS NOT NULL) has_secret FROM project_integrations WHERE project_id=$1 ORDER BY provider',
|
|
1189
|
+
[projectId]
|
|
1190
|
+
);
|
|
1191
|
+
res.json(result.rows);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
app.post('/projects/:projectId/integrations/:provider/resolve', auth, async (req: AuthedRequest, res) => {
|
|
1195
|
+
const projectId = routeParam(req.params.projectId);
|
|
1196
|
+
if (!(await hasProjectAccess(projectId, req.auth!))) { res.status(404).json({ error: 'Project not found or access denied' }); return; }
|
|
1197
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1198
|
+
let provider: string;
|
|
1199
|
+
try { provider = providerName(routeParam(req.params.provider)); }
|
|
1200
|
+
catch { res.status(400).json({ error: 'Invalid provider name' }); return; }
|
|
1201
|
+
const result = await pool.query('SELECT config,encrypted_secret FROM project_integrations WHERE project_id=$1 AND provider=$2', [projectId, provider]);
|
|
1202
|
+
if (!result.rowCount) { res.status(404).json({ error: 'Provider integration not found' }); return; }
|
|
1203
|
+
const encrypted = result.rows[0].encrypted_secret;
|
|
1204
|
+
const secret = encrypted ? JSON.parse(decrypt(encrypted)) : null;
|
|
1205
|
+
await audit(req.auth!.id, 'integration.secret.resolve.legacy', 'project', projectId, reason, { provider });
|
|
1206
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1207
|
+
res.json({ config: result.rows[0].config || {}, secret });
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
app.post('/audit', auth, async (req: AuthedRequest, res) => {
|
|
1211
|
+
const reason = requireReason(req, res); if (!reason) return;
|
|
1212
|
+
const { action, resourceType, resourceId, metadata = {} } = req.body || {};
|
|
1213
|
+
if (!action || !resourceType) { res.status(400).json({ error: 'action and resourceType are required' }); return; }
|
|
1214
|
+
await audit(req.auth!.id, String(action), String(resourceType), resourceId ? String(resourceId) : undefined, reason, metadata);
|
|
1215
|
+
res.status(201).json({ ok: true });
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
app.get('/logs', ...allow('manager', 'admin'), async (req, res) => {
|
|
1219
|
+
const { search, limit } = listQuery(req);
|
|
1220
|
+
res.json((await pool.query(
|
|
1221
|
+
`SELECT l.*,u.email actor_email
|
|
1222
|
+
FROM audit_logs l LEFT JOIN users u ON u.id=l.actor_id
|
|
1223
|
+
WHERE ($1='' OR coalesce(u.email,'') ILIKE '%'||$1||'%' OR l.action ILIKE '%'||$1||'%' OR l.resource_type ILIKE '%'||$1||'%' OR coalesce(l.resource_id,'') ILIKE '%'||$1||'%' OR coalesce(l.reason,'') ILIKE '%'||$1||'%')
|
|
1224
|
+
ORDER BY l.created_at DESC LIMIT $2`, [search, limit]
|
|
1225
|
+
)).rows);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
1229
|
+
console.error(err);
|
|
1230
|
+
const error = err as { code?: string; message?: string; statusCode?: number };
|
|
1231
|
+
if (error.statusCode) { res.status(error.statusCode).json({ error: error.message || 'Request failed' }); return; }
|
|
1232
|
+
if (error.code === '22P02') { res.status(400).json({ error: 'Invalid identifier. If this came from .indraq/project.json, run `indraq project link` to repair the local project reference.' }); return; }
|
|
1233
|
+
if (error.code === '23505') { res.status(409).json({ error: 'Resource already exists' }); return; }
|
|
1234
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
const bootstrap = async (): Promise<void> => {
|
|
1238
|
+
const email = process.env.BOOTSTRAP_ADMIN_EMAIL;
|
|
1239
|
+
const password = process.env.BOOTSTRAP_ADMIN_PASSWORD;
|
|
1240
|
+
if (!email || !password) return;
|
|
1241
|
+
const result = await pool.query('SELECT 1 FROM users WHERE role=$1 LIMIT 1', ['admin']);
|
|
1242
|
+
if (!result.rowCount) {
|
|
1243
|
+
if (password.length < 12) throw new Error('BOOTSTRAP_ADMIN_PASSWORD must be at least 12 characters.');
|
|
1244
|
+
await pool.query(
|
|
1245
|
+
'INSERT INTO users(email,name,password_hash,role) VALUES(lower($1),$2,$3,$4)',
|
|
1246
|
+
[email, 'IndraQ Admin', await bcrypt.hash(password, 12), 'admin']
|
|
1247
|
+
);
|
|
1248
|
+
console.log('Bootstrap admin created.');
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
await runMigrations();
|
|
1253
|
+
await bootstrap();
|
|
1254
|
+
app.listen(Number(process.env.PORT || 4010), () => console.log(`IndraQ Cloud API listening on ${process.env.PORT || 4010}`));
|