indraq_cli 2.1.0 → 2.2.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.
Files changed (79) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +41 -15
  3. package/cloud-api/.dockerignore +18 -18
  4. package/cloud-api/.env.example +14 -14
  5. package/cloud-api/Dockerfile +19 -19
  6. package/cloud-api/README.md +93 -93
  7. package/cloud-api/dist/index.js +265 -27
  8. package/cloud-api/node_modules/.package-lock.json +1 -1
  9. package/cloud-api/package-lock.json +2 -2
  10. package/cloud-api/package.json +1 -1
  11. package/cloud-api/sql/001_init.sql +8 -8
  12. package/cloud-api/sql/002_user_providers_and_project_resources.sql +40 -40
  13. package/cloud-api/sql/003_environment_infrastructure.sql +19 -19
  14. package/cloud-api/sql/004_managed_aws_credentials.sql +20 -20
  15. package/cloud-api/sql/005_project_members_and_resource_grants.sql +22 -22
  16. package/cloud-api/sql/006_catalog_identity_password_security.sql +96 -96
  17. package/cloud-api/sql/007_aws_access_key_rotation.sql +16 -16
  18. package/cloud-api/sql/008_provider_scopes.sql +91 -91
  19. package/cloud-api/sql/009_scope_user_secrets_and_project_lifecycle.sql +13 -0
  20. package/cloud-api/src/index.ts +164 -27
  21. package/cloud-api/tsconfig.json +18 -18
  22. package/dist/cli/help-content.d.ts.map +1 -1
  23. package/dist/cli/help-content.js +29 -7
  24. package/dist/cli/help-content.js.map +1 -1
  25. package/dist/modules/access/commands/access.command.js +30 -30
  26. package/dist/modules/aws/services/route53-lookup.service.js.map +1 -1
  27. package/dist/modules/cloud/commands/cloud.command.d.ts +4 -0
  28. package/dist/modules/cloud/commands/cloud.command.d.ts.map +1 -1
  29. package/dist/modules/cloud/commands/cloud.command.js +52 -1
  30. package/dist/modules/cloud/commands/cloud.command.js.map +1 -1
  31. package/dist/modules/cloud/index.d.ts.map +1 -1
  32. package/dist/modules/cloud/index.js +6 -3
  33. package/dist/modules/cloud/index.js.map +1 -1
  34. package/dist/modules/cloud/services/cloud-jenkins.service.js +71 -71
  35. package/dist/modules/cloud/services/user-provider.service.d.ts +2 -8
  36. package/dist/modules/cloud/services/user-provider.service.d.ts.map +1 -1
  37. package/dist/modules/cloud/services/user-provider.service.js +7 -35
  38. package/dist/modules/cloud/services/user-provider.service.js.map +1 -1
  39. package/dist/modules/deploy/commands/build.command.d.ts +1 -0
  40. package/dist/modules/deploy/commands/build.command.d.ts.map +1 -1
  41. package/dist/modules/deploy/commands/build.command.js +12 -5
  42. package/dist/modules/deploy/commands/build.command.js.map +1 -1
  43. package/dist/modules/deploy/index.d.ts.map +1 -1
  44. package/dist/modules/deploy/index.js +11 -4
  45. package/dist/modules/deploy/index.js.map +1 -1
  46. package/dist/modules/prebuild/commands/prebuild.command.d.ts +0 -4
  47. package/dist/modules/prebuild/commands/prebuild.command.d.ts.map +1 -1
  48. package/dist/modules/prebuild/commands/prebuild.command.js +34 -38
  49. package/dist/modules/prebuild/commands/prebuild.command.js.map +1 -1
  50. package/dist/modules/prebuild/index.d.ts.map +1 -1
  51. package/dist/modules/prebuild/index.js +4 -8
  52. package/dist/modules/prebuild/index.js.map +1 -1
  53. package/dist/modules/scaffold/services/docker-template.service.js +95 -95
  54. package/dist/modules/scopes/commands/scope.command.d.ts.map +1 -1
  55. package/dist/modules/scopes/commands/scope.command.js +3 -47
  56. package/dist/modules/scopes/commands/scope.command.js.map +1 -1
  57. package/dist/modules/scopes/services/scope-native.service.js +2 -2
  58. package/dist/modules/scopes/services/scope-native.service.js.map +1 -1
  59. package/dist/modules/users/commands/user.command.d.ts +1 -0
  60. package/dist/modules/users/commands/user.command.d.ts.map +1 -1
  61. package/dist/modules/users/commands/user.command.js +15 -1
  62. package/dist/modules/users/commands/user.command.js.map +1 -1
  63. package/dist/modules/users/index.d.ts.map +1 -1
  64. package/dist/modules/users/index.js +3 -2
  65. package/dist/modules/users/index.js.map +1 -1
  66. package/dist/modules/users/providers/jenkins-user.provider.js +95 -95
  67. package/dist/shared/config/config-files.js +7 -7
  68. package/dist/shared/jenkins/jenkins.service.d.ts.map +1 -1
  69. package/dist/shared/jenkins/jenkins.service.js +13 -8
  70. package/dist/shared/jenkins/jenkins.service.js.map +1 -1
  71. package/dist/shared/jenkins/role-strategy-groovy.js +84 -84
  72. package/docs/ARCHITECTURE.md +29 -29
  73. package/docs/CREATE-DEPLOYMENT-reference.groovy +1105 -1105
  74. package/package.json +9 -5
  75. package/templates/jenkins/CREATE-DEPLOYMENT.groovy +1105 -1105
  76. package/templates/jenkins/Jenkinsfile-Mobile-App +759 -759
  77. package/CHANGELOG_v2.1.0.md +0 -27
  78. package/UPGRADE_v2.0_to_v2.1.md +0 -68
  79. package/cloud-api/dist/index.js.map +0 -1
@@ -106,28 +106,31 @@ const listQuery = (req, defaultLimit = 10, maximum = 5000) => {
106
106
  const limit = all ? maximum : Math.max(1, Math.min(maximum, Number.isInteger(parsed) ? parsed : defaultLimit));
107
107
  return { search, limit, all };
108
108
  };
109
- const projectAccessLevel = async (projectIdRaw, user) => {
109
+ const projectAccessLevel = async (projectIdRaw, user, includeArchived = false) => {
110
110
  const projectId = ensureUuid(projectIdRaw, 'Project ID');
111
- const result = await pool.query(`SELECT p.id,p.created_by,pm.role,pm.access_level
111
+ const result = await pool.query(`SELECT p.id,p.created_by,p.archived_at,pm.role,pm.access_level
112
112
  FROM projects p
113
113
  LEFT JOIN project_members pm ON pm.project_id=p.id AND pm.user_id=$2
114
114
  WHERE p.id=$1
115
115
  LIMIT 1`, [projectId, user.id]);
116
116
  if (!result.rowCount)
117
117
  return null;
118
- if (user.role === 'admin' || user.role === 'manager')
119
- return 'write';
120
118
  const row = result.rows[0];
119
+ if (row.archived_at && !includeArchived)
120
+ return null;
121
+ if (user.role === 'admin')
122
+ return 'write';
121
123
  if (row.created_by === user.id || row.role === 'owner' || row.access_level === 'write')
122
124
  return 'write';
123
- // Every authenticated organization user can discover/read every project.
124
- return 'view';
125
+ if (row.role || row.access_level === 'view')
126
+ return 'view';
127
+ return null;
125
128
  };
126
129
  const hasProjectAccess = async (projectId, user) => (await projectAccessLevel(projectId, user)) !== null;
127
130
  const hasProjectWriteAccess = async (projectId, user) => (await projectAccessLevel(projectId, user)) === 'write';
128
131
  const hasProjectResourceAccess = async (resourceIdRaw, user, manage = false) => {
129
132
  const resourceId = ensureUuid(resourceIdRaw, 'Project resource ID');
130
- if (user.role === 'admin' || user.role === 'manager')
133
+ if (user.role === 'admin')
131
134
  return true;
132
135
  const resource = await pool.query('SELECT project_id,created_by FROM project_resources WHERE id=$1', [resourceId]);
133
136
  if (!resource.rowCount)
@@ -871,10 +874,10 @@ app.post('/me/aws-credentials/:awsUsername/resolve', auth, async (req, res) => {
871
874
  });
872
875
  app.get('/projects', auth, async (req, res) => {
873
876
  const { search, limit } = listQuery(req);
874
- const privileged = req.auth.role === 'manager' || req.auth.role === 'admin';
877
+ const archivedMode = req.query.archived === 'only' ? 'only' : req.query.archived === 'all' ? 'all' : 'active';
875
878
  const result = await pool.query(`SELECT p.*,u.email AS creator_email,
876
879
  CASE
877
- WHEN $3::boolean THEN 'write'
880
+ WHEN $3='admin' THEN 'write'
878
881
  WHEN p.created_by=$1 THEN 'write'
879
882
  WHEN pm.role='owner' OR pm.access_level='write' THEN 'write'
880
883
  ELSE 'view'
@@ -882,9 +885,11 @@ app.get('/projects', auth, async (req, res) => {
882
885
  FROM projects p
883
886
  JOIN users u ON u.id=p.created_by
884
887
  LEFT JOIN project_members pm ON pm.project_id=p.id AND pm.user_id=$1
885
- WHERE ($2='' OR p.name ILIKE '%'||$2||'%' OR p.kind ILIKE '%'||$2||'%' OR u.email ILIKE '%'||$2||'%')
888
+ WHERE ($3='admin' OR p.created_by=$1 OR pm.user_id=$1)
889
+ AND ($2='' OR p.name ILIKE '%'||$2||'%' OR p.kind ILIKE '%'||$2||'%' OR u.email ILIKE '%'||$2||'%')
890
+ AND ($4='all' OR ($4='only' AND p.archived_at IS NOT NULL) OR ($4='active' AND p.archived_at IS NULL))
886
891
  ORDER BY p.created_at DESC
887
- LIMIT $4`, [req.auth.id, search, privileged, limit]);
892
+ LIMIT $5`, [req.auth.id, search, req.auth.role, archivedMode, limit]);
888
893
  res.json(result.rows);
889
894
  });
890
895
  app.post('/projects', auth, async (req, res) => {
@@ -927,6 +932,10 @@ app.patch('/projects/:id', ...allow('manager', 'admin'), async (req, res) => {
927
932
  if (!reason)
928
933
  return;
929
934
  const id = ensureUuid(routeParam(req.params.id), 'Project ID');
935
+ if (!(await hasProjectWriteAccess(id, req.auth))) {
936
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
937
+ return;
938
+ }
930
939
  if (typeof req.body?.name === 'string' && req.body.name.trim()) {
931
940
  const duplicate = await pool.query('SELECT id FROM projects WHERE lower(name)=lower($1) AND id<>$2 LIMIT 1', [req.body.name.trim(), id]);
932
941
  if (duplicate.rowCount) {
@@ -946,7 +955,11 @@ app.delete('/projects/:id', ...allow('manager', 'admin'), async (req, res) => {
946
955
  const reason = requireReason(req, res);
947
956
  if (!reason)
948
957
  return;
949
- const id = routeParam(req.params.id);
958
+ const id = ensureUuid(routeParam(req.params.id), 'Project ID');
959
+ if ((await projectAccessLevel(id, req.auth, true)) !== 'write') {
960
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
961
+ return;
962
+ }
950
963
  const result = await pool.query('DELETE FROM projects WHERE id=$1 RETURNING id,name', [id]);
951
964
  if (!result.rowCount) {
952
965
  res.status(404).json({ error: 'Project not found' });
@@ -955,8 +968,46 @@ app.delete('/projects/:id', ...allow('manager', 'admin'), async (req, res) => {
955
968
  await audit(req.auth.id, 'project.delete', 'project', id, reason, { name: result.rows[0].name });
956
969
  res.status(204).end();
957
970
  });
971
+ app.post('/projects/:id/archive', ...allow('manager', 'admin'), async (req, res) => {
972
+ const reason = requireReason(req, res);
973
+ if (!reason)
974
+ return;
975
+ const id = ensureUuid(routeParam(req.params.id), 'Project ID');
976
+ if ((await projectAccessLevel(id, req.auth, true)) !== 'write') {
977
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
978
+ return;
979
+ }
980
+ const result = await pool.query('UPDATE projects SET archived_at=COALESCE(archived_at,now()),archived_by=COALESCE(archived_by,$2),updated_at=now() WHERE id=$1 RETURNING id,name,archived_at,archived_by', [id, req.auth.id]);
981
+ if (!result.rowCount) {
982
+ res.status(404).json({ error: 'Project not found' });
983
+ return;
984
+ }
985
+ await audit(req.auth.id, 'project.archive', 'project', id, reason, { name: result.rows[0].name });
986
+ res.json(result.rows[0]);
987
+ });
988
+ app.post('/projects/:id/restore', ...allow('manager', 'admin'), async (req, res) => {
989
+ const reason = requireReason(req, res);
990
+ if (!reason)
991
+ return;
992
+ const id = ensureUuid(routeParam(req.params.id), 'Project ID');
993
+ if ((await projectAccessLevel(id, req.auth, true)) !== 'write') {
994
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
995
+ return;
996
+ }
997
+ const result = await pool.query('UPDATE projects SET archived_at=NULL,archived_by=NULL,updated_at=now() WHERE id=$1 RETURNING id,name,archived_at,archived_by', [id]);
998
+ if (!result.rowCount) {
999
+ res.status(404).json({ error: 'Project not found' });
1000
+ return;
1001
+ }
1002
+ await audit(req.auth.id, 'project.restore', 'project', id, reason, { name: result.rows[0].name });
1003
+ res.json(result.rows[0]);
1004
+ });
958
1005
  app.get('/projects/:id/members', ...allow('manager', 'admin'), async (req, res) => {
959
- const id = routeParam(req.params.id);
1006
+ const id = ensureUuid(routeParam(req.params.id), 'Project ID');
1007
+ if (!(await hasProjectWriteAccess(id, req.auth))) {
1008
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
1009
+ return;
1010
+ }
960
1011
  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
961
1012
  FROM project_members pm JOIN users u ON u.id=pm.user_id
962
1013
  WHERE pm.project_id=$1 AND u.is_active=true ORDER BY u.email`, [id]);
@@ -966,7 +1017,11 @@ app.post('/projects/:id/members', ...allow('manager', 'admin'), async (req, res)
966
1017
  const reason = requireReason(req, res);
967
1018
  if (!reason)
968
1019
  return;
969
- const id = routeParam(req.params.id);
1020
+ const id = ensureUuid(routeParam(req.params.id), 'Project ID');
1021
+ if (!(await hasProjectWriteAccess(id, req.auth))) {
1022
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
1023
+ return;
1024
+ }
970
1025
  const userId = String(req.body?.userId || '');
971
1026
  const project = await pool.query('SELECT id FROM projects WHERE id=$1', [id]);
972
1027
  if (!project.rowCount) {
@@ -1002,7 +1057,11 @@ app.delete('/projects/:id/members/:userId', ...allow('manager', 'admin'), async
1002
1057
  const reason = requireReason(req, res);
1003
1058
  if (!reason)
1004
1059
  return;
1005
- const id = routeParam(req.params.id);
1060
+ const id = ensureUuid(routeParam(req.params.id), 'Project ID');
1061
+ if (!(await hasProjectWriteAccess(id, req.auth))) {
1062
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
1063
+ return;
1064
+ }
1006
1065
  const userId = routeParam(req.params.userId);
1007
1066
  const target = await pool.query('SELECT email,role FROM users WHERE id=$1', [userId]);
1008
1067
  if (req.auth.role === 'manager' && target.rows[0]?.role && target.rows[0].role !== 'user') {
@@ -1235,6 +1294,10 @@ app.post('/scopes/:id/resolve', auth, async (req, res) => {
1235
1294
  res.status(404).json({ error: 'Scope not found or access denied' });
1236
1295
  return;
1237
1296
  }
1297
+ if (access === 'use') {
1298
+ res.status(403).json({ error: 'MANAGE access is required to resolve the provider management credential.' });
1299
+ return;
1300
+ }
1238
1301
  const result = await pool.query('SELECT id,provider,name,description,company,config,encrypted_secret FROM provider_scopes WHERE id=$1 AND is_active=true', [id]);
1239
1302
  if (!result.rowCount) {
1240
1303
  res.status(404).json({ error: 'Scope not found' });
@@ -1333,6 +1396,15 @@ app.get('/scopes/:id/audit', auth, async (req, res) => {
1333
1396
  ORDER BY a.created_at DESC LIMIT $2`, [id, limit]);
1334
1397
  res.json(result.rows);
1335
1398
  });
1399
+ app.get('/me/scopes', auth, async (req, res) => {
1400
+ const result = await pool.query(`SELECT ps.id,ps.provider,ps.name,ps.description,ps.company,ps.config,(ps.encrypted_secret IS NOT NULL) has_secret,
1401
+ CASE WHEN $2='admin' THEN 'manage' ELSE psm.access END AS access
1402
+ FROM provider_scopes ps
1403
+ LEFT JOIN provider_scope_members psm ON psm.scope_id=ps.id AND psm.user_id=$1
1404
+ WHERE ps.is_active=true AND ($2='admin' OR psm.user_id=$1)
1405
+ ORDER BY ps.provider,lower(ps.name)`, [req.auth.id, req.auth.role]);
1406
+ res.json(result.rows);
1407
+ });
1336
1408
  app.get('/me/scope-defaults', auth, async (req, res) => {
1337
1409
  const result = await pool.query(`SELECT usd.provider,usd.scope_id,ps.name,ps.company,ps.config,ps.updated_at
1338
1410
  FROM user_scope_defaults usd JOIN provider_scopes ps ON ps.id=usd.scope_id
@@ -1399,20 +1471,48 @@ app.post('/me/scopes/:provider/resolve', auth, async (req, res) => {
1399
1471
  const visible = req.auth.role === 'admin'
1400
1472
  ? await pool.query('SELECT id FROM provider_scopes WHERE provider=$1 AND is_active=true ORDER BY created_at', [provider])
1401
1473
  : await pool.query('SELECT ps.id FROM provider_scopes ps JOIN provider_scope_members psm ON psm.scope_id=ps.id WHERE psm.user_id=$1 AND ps.provider=$2 AND ps.is_active=true ORDER BY ps.created_at', [req.auth.id, provider]);
1402
- if (visible.rowCount === 1)
1474
+ if (visible.rows.length === 1)
1403
1475
  scopeId = visible.rows[0].id;
1404
- else if (visible.rowCount > 1) {
1476
+ else if (visible.rows.length > 1) {
1405
1477
  res.status(409).json({ error: `Multiple ${provider} scopes are available. Link one to the project environment or set your default with \`indraq scope default\`.` });
1406
1478
  return;
1407
1479
  }
1408
1480
  }
1409
- if (!scopeId || !(await scopeVisible(scopeId, req.auth))) {
1481
+ const access = scopeId ? await scopeAccessFor(scopeId, req.auth) : null;
1482
+ if (!scopeId || !access) {
1410
1483
  res.status(404).json({ error: `No ${provider} scope is assigned to your account.` });
1411
1484
  return;
1412
1485
  }
1413
1486
  const row = (await pool.query('SELECT id,provider,name,description,company,config,encrypted_secret FROM provider_scopes WHERE id=$1 AND is_active=true', [scopeId])).rows[0];
1414
- await audit(req.auth.id, 'scope.secret.resolve', 'provider-scope', scopeId, reason, { provider, projectId: projectId || null, environment: environment || null });
1415
- res.json({ id: row.id, provider: row.provider, name: row.name, description: row.description, company: row.company, config: row.config || {}, secret: row.encrypted_secret ? JSON.parse(decrypt(row.encrypted_secret)) : null });
1487
+ let secret = null;
1488
+ let credentialSource = 'scope-management';
1489
+ if (req.auth.role !== 'admin' && provider === 'aws') {
1490
+ const own = await pool.query('SELECT access_key_id,encrypted_secret_access_key FROM scope_aws_credentials WHERE scope_id=$1 AND cloud_user_id=$2', [scopeId, req.auth.id]);
1491
+ if (!own.rowCount) {
1492
+ res.status(404).json({ error: 'No AWS credential is provisioned for you in this scope.' });
1493
+ return;
1494
+ }
1495
+ secret = { accessKeyId: own.rows[0].access_key_id, secretAccessKey: decrypt(own.rows[0].encrypted_secret_access_key) };
1496
+ credentialSource = 'user-native';
1497
+ }
1498
+ else if (req.auth.role !== 'admin' && ['npm', 'jenkins-dev', 'jenkins-prod'].includes(provider)) {
1499
+ const own = await pool.query('SELECT external_username,encrypted_secret FROM scope_external_accounts WHERE scope_id=$1 AND cloud_user_id=$2', [scopeId, req.auth.id]);
1500
+ if (!own.rowCount || !own.rows[0].encrypted_secret) {
1501
+ res.status(404).json({ error: `No ${provider} credential is provisioned for you in this scope. Rotate your provider password or ask a scope manager to reprovision you.` });
1502
+ return;
1503
+ }
1504
+ secret = provider === 'npm'
1505
+ ? { identity: own.rows[0].external_username, secret: decrypt(own.rows[0].encrypted_secret) }
1506
+ : { username: own.rows[0].external_username, secret: decrypt(own.rows[0].encrypted_secret) };
1507
+ credentialSource = 'user-native';
1508
+ }
1509
+ else {
1510
+ secret = row.encrypted_secret ? JSON.parse(decrypt(row.encrypted_secret)) : null;
1511
+ }
1512
+ await audit(req.auth.id, 'scope.credential.resolve', 'provider-scope', scopeId, reason, { provider, projectId: projectId || null, environment: environment || null, credentialSource });
1513
+ res.setHeader('Cache-Control', 'no-store');
1514
+ res.setHeader('Pragma', 'no-cache');
1515
+ res.json({ id: row.id, provider: row.provider, name: row.name, description: row.description, company: row.company, config: row.config || {}, secret, access: access === 'admin' ? 'manage' : access });
1416
1516
  });
1417
1517
  app.get('/projects/:projectId/environment-scopes', auth, async (req, res) => {
1418
1518
  const projectId = ensureUuid(routeParam(req.params.projectId), 'Project ID');
@@ -1558,10 +1658,11 @@ app.put('/scopes/:scopeId/users/:userId/external-account', ...allow('manager', '
1558
1658
  return;
1559
1659
  }
1560
1660
  const externalEmail = req.body?.externalEmail ? String(req.body.externalEmail).trim() : null;
1561
- const result = await pool.query(`INSERT INTO scope_external_accounts(scope_id,cloud_user_id,provider,external_username,external_email,managed_by_user_id,config)
1562
- VALUES($1,$2,$3,$4,$5,$6,$7)
1563
- ON CONFLICT(scope_id,cloud_user_id) 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()
1564
- RETURNING id,scope_id,cloud_user_id,provider,external_username,external_email,config,updated_at`, [scopeId, userId, scope.rows[0].provider, externalUsername, externalEmail, req.auth.id, req.body?.config || {}]);
1661
+ const encryptedSecret = req.body?.secret ? encrypt(String(req.body.secret)) : null;
1662
+ const result = await pool.query(`INSERT INTO scope_external_accounts(scope_id,cloud_user_id,provider,external_username,external_email,managed_by_user_id,config,encrypted_secret)
1663
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8)
1664
+ ON CONFLICT(scope_id,cloud_user_id) 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,encrypted_secret=COALESCE(excluded.encrypted_secret,scope_external_accounts.encrypted_secret),updated_at=now()
1665
+ RETURNING id,scope_id,cloud_user_id,provider,external_username,external_email,config,updated_at`, [scopeId, userId, scope.rows[0].provider, externalUsername, externalEmail, req.auth.id, req.body?.config || {}, encryptedSecret]);
1565
1666
  await audit(req.auth.id, 'scope.native-account.upsert', 'provider-scope', scopeId, reason, { userId, provider: scope.rows[0].provider, externalUsername, externalEmail });
1566
1667
  res.json(result.rows[0]);
1567
1668
  });
@@ -1699,6 +1800,128 @@ app.post('/me/scopes/:scopeId/aws-credential/resolve', auth, async (req, res) =>
1699
1800
  await audit(req.auth.id, 'scope.aws-credential.resolve', 'provider-scope', scopeId, reason, { awsUsername: row.aws_username, accessKeyId: row.access_key_id });
1700
1801
  res.json({ awsUsername: row.aws_username, accessKeyId: row.access_key_id, secretAccessKey: decrypt(row.encrypted_secret_access_key), indraqRole: row.indraq_role });
1701
1802
  });
1803
+ app.post('/me/scopes/:scopeId/aws-credential/rotate', auth, async (req, res) => {
1804
+ const reason = requireReason(req, res);
1805
+ if (!reason)
1806
+ return;
1807
+ const scopeId = ensureUuid(routeParam(req.params.scopeId), 'Scope ID');
1808
+ if (!(await scopeVisible(scopeId, req.auth))) {
1809
+ res.status(403).json({ error: 'Scope access denied.' });
1810
+ return;
1811
+ }
1812
+ const own = await pool.query('SELECT aws_username,access_key_id,encrypted_secret_access_key FROM scope_aws_credentials WHERE scope_id=$1 AND cloud_user_id=$2', [scopeId, req.auth.id]);
1813
+ if (!own.rowCount) {
1814
+ res.status(404).json({ error: 'No AWS credential is stored for you in this scope.' });
1815
+ return;
1816
+ }
1817
+ const scope = await pool.query('SELECT config,encrypted_secret FROM provider_scopes WHERE id=$1 AND provider=\'aws\' AND is_active=true', [scopeId]);
1818
+ if (!scope.rowCount || !scope.rows[0].encrypted_secret) {
1819
+ res.status(409).json({ error: 'AWS scope management credential is unavailable.' });
1820
+ return;
1821
+ }
1822
+ const management = JSON.parse(decrypt(scope.rows[0].encrypted_secret));
1823
+ const region = String(scope.rows[0].config?.region || 'us-east-1');
1824
+ const iam = new IAMClient({ region, credentials: { accessKeyId: management.accessKeyId, secretAccessKey: management.secretAccessKey, ...(management.sessionToken ? { sessionToken: management.sessionToken } : {}) } });
1825
+ const created = await iam.send(new CreateAccessKeyCommand({ UserName: own.rows[0].aws_username }));
1826
+ const next = created.AccessKey;
1827
+ if (!next?.AccessKeyId || !next.SecretAccessKey) {
1828
+ res.status(502).json({ error: 'AWS did not return a replacement access key.' });
1829
+ return;
1830
+ }
1831
+ try {
1832
+ await pool.query('UPDATE scope_aws_credentials SET access_key_id=$1,encrypted_secret_access_key=$2,updated_at=now() WHERE scope_id=$3 AND cloud_user_id=$4', [next.AccessKeyId, encrypt(next.SecretAccessKey), scopeId, req.auth.id]);
1833
+ await iam.send(new DeleteAccessKeyCommand({ UserName: own.rows[0].aws_username, AccessKeyId: own.rows[0].access_key_id }));
1834
+ }
1835
+ catch (error) {
1836
+ try {
1837
+ await iam.send(new DeleteAccessKeyCommand({ UserName: own.rows[0].aws_username, AccessKeyId: next.AccessKeyId }));
1838
+ }
1839
+ catch { /* best effort */ }
1840
+ throw error;
1841
+ }
1842
+ await audit(req.auth.id, 'scope.aws-credential.self-rotate', 'provider-scope', scopeId, reason, { awsUsername: own.rows[0].aws_username, previousAccessKeyId: own.rows[0].access_key_id, accessKeyId: next.AccessKeyId });
1843
+ res.setHeader('Cache-Control', 'no-store');
1844
+ res.setHeader('Pragma', 'no-cache');
1845
+ res.json({ awsUsername: own.rows[0].aws_username, accessKeyId: next.AccessKeyId, secretAccessKey: next.SecretAccessKey });
1846
+ });
1847
+ app.post('/me/scopes/:scopeId/external-account/rotate', auth, async (req, res) => {
1848
+ const reason = requireReason(req, res);
1849
+ if (!reason)
1850
+ return;
1851
+ const scopeId = ensureUuid(routeParam(req.params.scopeId), 'Scope ID');
1852
+ if (!(await scopeVisible(scopeId, req.auth))) {
1853
+ res.status(403).json({ error: 'Scope access denied.' });
1854
+ return;
1855
+ }
1856
+ const password = String(req.body?.password || '');
1857
+ if (password.length < 10) {
1858
+ res.status(400).json({ error: 'Password must be at least 10 characters.' });
1859
+ return;
1860
+ }
1861
+ const account = await pool.query('SELECT provider,external_username FROM scope_external_accounts WHERE scope_id=$1 AND cloud_user_id=$2', [scopeId, req.auth.id]);
1862
+ if (!account.rowCount) {
1863
+ res.status(404).json({ error: 'No NPM/Jenkins account is stored for you in this scope.' });
1864
+ return;
1865
+ }
1866
+ const scope = await pool.query('SELECT provider,config,encrypted_secret FROM provider_scopes WHERE id=$1 AND is_active=true', [scopeId]);
1867
+ if (!scope.rowCount || !scope.rows[0].encrypted_secret) {
1868
+ res.status(409).json({ error: 'Provider management credential is unavailable.' });
1869
+ return;
1870
+ }
1871
+ const provider = String(account.rows[0].provider);
1872
+ const management = JSON.parse(decrypt(scope.rows[0].encrypted_secret));
1873
+ const username = String(account.rows[0].external_username);
1874
+ if (provider === 'npm') {
1875
+ const base = String(scope.rows[0].config?.url || '').replace(/\/+$/, '');
1876
+ const tokenResponse = await fetch(`${base}/api/tokens`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identity: management.identity, secret: management.secret }) });
1877
+ if (!tokenResponse.ok) {
1878
+ res.status(502).json({ error: `NPM management authentication failed (HTTP ${tokenResponse.status}).` });
1879
+ return;
1880
+ }
1881
+ const token = (await tokenResponse.json()).token;
1882
+ if (!token) {
1883
+ res.status(502).json({ error: 'NPM did not return a management token.' });
1884
+ return;
1885
+ }
1886
+ const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
1887
+ const usersResponse = await fetch(`${base}/api/users?expand=permissions`, { headers });
1888
+ if (!usersResponse.ok) {
1889
+ res.status(502).json({ error: `Unable to list NPM users (HTTP ${usersResponse.status}).` });
1890
+ return;
1891
+ }
1892
+ const users = await usersResponse.json();
1893
+ const wanted = username.toLowerCase();
1894
+ const target = users.find((u) => String(u.email || '').toLowerCase() === wanted || String(u.nickname || '').toLowerCase() === wanted || String(u.name || '').toLowerCase() === wanted);
1895
+ if (!target) {
1896
+ res.status(404).json({ error: `NPM user ${username} was not found.` });
1897
+ return;
1898
+ }
1899
+ const update = await fetch(`${base}/api/users/${target.id}/auth`, { method: 'PUT', headers, body: JSON.stringify({ type: 'password', secret: password }) });
1900
+ if (!update.ok) {
1901
+ res.status(502).json({ error: `NPM password reset failed (HTTP ${update.status}).` });
1902
+ return;
1903
+ }
1904
+ }
1905
+ else if (provider === 'jenkins-dev' || provider === 'jenkins-prod') {
1906
+ const url = String(scope.rows[0].config?.url || '').replace(/\/+$/, '');
1907
+ const adminUsername = String(management.username || '');
1908
+ const adminSecret = String(management.secret || '');
1909
+ const headers = await jenkinsRequestHeaders(url, adminUsername, adminSecret);
1910
+ 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(password)}))\nuser.save()\nprintln 'updated'`;
1911
+ const response = await fetch(`${url}/scriptText`, { method: 'POST', headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }).toString() });
1912
+ if (!response.ok) {
1913
+ res.status(502).json({ error: `Jenkins password reset failed (HTTP ${response.status}).` });
1914
+ return;
1915
+ }
1916
+ }
1917
+ else {
1918
+ res.status(400).json({ error: 'Scope must be NPM or Jenkins.' });
1919
+ return;
1920
+ }
1921
+ await pool.query('UPDATE scope_external_accounts SET encrypted_secret=$1,updated_at=now() WHERE scope_id=$2 AND cloud_user_id=$3', [encrypt(password), scopeId, req.auth.id]);
1922
+ await audit(req.auth.id, 'scope.external-account.self-rotate', 'provider-scope', scopeId, reason, { provider, username });
1923
+ res.status(204).end();
1924
+ });
1702
1925
  // Reusable provider credentials are scoped to the authenticated IndraQ user.
1703
1926
  // A user configures AWS/NPM/Jenkins/GHCR once and can reuse that profile across
1704
1927
  // every project they are allowed to access. Secrets are encrypted at rest and
@@ -1969,17 +2192,25 @@ app.get('/users/:id/resource-grants', ...allow('manager', 'admin'), async (req,
1969
2192
  res.status(403).json({ error: 'Managers may inspect grants only for normal users.' });
1970
2193
  return;
1971
2194
  }
2195
+ const managerFilter = req.auth.role === 'manager'
2196
+ ? ` AND (p.created_by=$2 OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=$2 AND (pm.role='owner' OR pm.access_level='write')))`
2197
+ : '';
2198
+ const params = req.auth.role === 'manager' ? [userId, req.auth.id] : [userId];
1972
2199
  const result = await pool.query(`SELECT g.id,g.project_resource_id,g.user_id,g.permission,g.created_at,
1973
2200
  pr.project_id,p.name AS project_name,pr.resource_type,pr.provider,pr.environment,pr.external_id,pr.config
1974
2201
  FROM project_resource_grants g
1975
2202
  JOIN project_resources pr ON pr.id=g.project_resource_id
1976
2203
  JOIN projects p ON p.id=pr.project_id
1977
- WHERE g.user_id=$1
1978
- ORDER BY p.name,pr.provider,pr.resource_type`, [userId]);
2204
+ WHERE g.user_id=$1 AND p.archived_at IS NULL${managerFilter}
2205
+ ORDER BY p.name,pr.provider,pr.resource_type`, params);
1979
2206
  res.json(result.rows);
1980
2207
  });
1981
2208
  app.get('/projects/:projectId/resource-grants', ...allow('manager', 'admin'), async (req, res) => {
1982
2209
  const projectId = routeParam(req.params.projectId);
2210
+ if (!(await hasProjectWriteAccess(projectId, req.auth))) {
2211
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
2212
+ return;
2213
+ }
1983
2214
  const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined;
1984
2215
  const result = await pool.query(`SELECT g.id,g.project_resource_id,g.user_id,g.permission,g.created_at,
1985
2216
  u.email,u.name,pr.resource_type,pr.provider,pr.environment,pr.external_id,pr.config
@@ -1995,6 +2226,10 @@ app.post('/projects/:projectId/resources/:resourceId/grants', ...allow('manager'
1995
2226
  if (!reason)
1996
2227
  return;
1997
2228
  const projectId = routeParam(req.params.projectId);
2229
+ if (!(await hasProjectWriteAccess(projectId, req.auth))) {
2230
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
2231
+ return;
2232
+ }
1998
2233
  const resourceId = routeParam(req.params.resourceId);
1999
2234
  const userId = String(req.body?.userId || '');
2000
2235
  const permission = req.body?.permission === 'view' ? 'view' : 'manage';
@@ -2023,6 +2258,10 @@ app.delete('/projects/:projectId/resources/:resourceId/grants/:userId', ...allow
2023
2258
  if (!reason)
2024
2259
  return;
2025
2260
  const projectId = routeParam(req.params.projectId);
2261
+ if (!(await hasProjectWriteAccess(projectId, req.auth))) {
2262
+ res.status(403).json({ error: 'WRITE access to the project is required.' });
2263
+ return;
2264
+ }
2026
2265
  const resourceId = routeParam(req.params.resourceId);
2027
2266
  const userId = routeParam(req.params.userId);
2028
2267
  const target = await pool.query('SELECT email,role FROM users WHERE id=$1', [userId]);
@@ -2155,4 +2394,3 @@ const bootstrap = async () => {
2155
2394
  await runMigrations();
2156
2395
  await bootstrap();
2157
2396
  app.listen(Number(process.env.PORT || 4010), () => console.log(`IndraQ Cloud API listening on ${process.env.PORT || 4010}`));
2158
- //# sourceMappingURL=index.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indraq/cloud-api",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@indraq/cloud-api",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@indraq/cloud-api",
9
- "version": "0.6.0",
9
+ "version": "0.7.0",
10
10
  "dependencies": {
11
11
  "@aws-sdk/client-iam": "^3.1123.0",
12
12
  "bcryptjs": "^3.0.2",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@indraq/cloud-api",
3
3
  "private": true,
4
- "version": "0.6.0",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "tsx watch src/index.ts",
@@ -1,8 +1,8 @@
1
- CREATE EXTENSION IF NOT EXISTS pgcrypto;
2
- CREATE TABLE IF NOT EXISTS users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, name text NOT NULL, password_hash text NOT NULL, role text NOT NULL CHECK (role IN ('user','manager','admin')), is_active boolean NOT NULL DEFAULT true, created_by uuid REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
3
- CREATE TABLE IF NOT EXISTS projects (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, kind text NOT NULL, config jsonb NOT NULL DEFAULT '{}'::jsonb, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
4
- CREATE TABLE IF NOT EXISTS project_members (project_id uuid REFERENCES projects(id) ON DELETE CASCADE, user_id uuid REFERENCES users(id) ON DELETE CASCADE, role text NOT NULL DEFAULT 'user', PRIMARY KEY(project_id,user_id));
5
- CREATE TABLE IF NOT EXISTS servers (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL UNIQUE, environment text NOT NULL, ipv4 inet NOT NULL, health_path text DEFAULT '/health', is_backup boolean NOT NULL DEFAULT false, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now());
6
- CREATE TABLE IF NOT EXISTS project_integrations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, provider text NOT NULL, config jsonb NOT NULL DEFAULT '{}'::jsonb, encrypted_secret text, UNIQUE(project_id,provider));
7
- CREATE TABLE IF NOT EXISTS audit_logs (id bigserial PRIMARY KEY, actor_id uuid REFERENCES users(id), action text NOT NULL, resource_type text NOT NULL, resource_id text, reason text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now());
8
- CREATE INDEX IF NOT EXISTS audit_logs_created_at_idx ON audit_logs(created_at DESC);
1
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
2
+ CREATE TABLE IF NOT EXISTS users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, name text NOT NULL, password_hash text NOT NULL, role text NOT NULL CHECK (role IN ('user','manager','admin')), is_active boolean NOT NULL DEFAULT true, created_by uuid REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
3
+ CREATE TABLE IF NOT EXISTS projects (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, kind text NOT NULL, config jsonb NOT NULL DEFAULT '{}'::jsonb, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
4
+ CREATE TABLE IF NOT EXISTS project_members (project_id uuid REFERENCES projects(id) ON DELETE CASCADE, user_id uuid REFERENCES users(id) ON DELETE CASCADE, role text NOT NULL DEFAULT 'user', PRIMARY KEY(project_id,user_id));
5
+ CREATE TABLE IF NOT EXISTS servers (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL UNIQUE, environment text NOT NULL, ipv4 inet NOT NULL, health_path text DEFAULT '/health', is_backup boolean NOT NULL DEFAULT false, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now());
6
+ CREATE TABLE IF NOT EXISTS project_integrations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, provider text NOT NULL, config jsonb NOT NULL DEFAULT '{}'::jsonb, encrypted_secret text, UNIQUE(project_id,provider));
7
+ CREATE TABLE IF NOT EXISTS audit_logs (id bigserial PRIMARY KEY, actor_id uuid REFERENCES users(id), action text NOT NULL, resource_type text NOT NULL, resource_id text, reason text, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now());
8
+ CREATE INDEX IF NOT EXISTS audit_logs_created_at_idx ON audit_logs(created_at DESC);
@@ -1,40 +1,40 @@
1
- CREATE TABLE IF NOT EXISTS user_integrations (
2
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3
- user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4
- provider text NOT NULL,
5
- config jsonb NOT NULL DEFAULT '{}'::jsonb,
6
- encrypted_secret text,
7
- created_at timestamptz NOT NULL DEFAULT now(),
8
- updated_at timestamptz NOT NULL DEFAULT now(),
9
- UNIQUE(user_id, provider)
10
- );
11
-
12
- CREATE INDEX IF NOT EXISTS user_integrations_user_idx
13
- ON user_integrations(user_id, provider);
14
-
15
- CREATE TABLE IF NOT EXISTS project_resources (
16
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
17
- project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
18
- environment text NOT NULL DEFAULT 'dev',
19
- resource_type text NOT NULL,
20
- provider text NOT NULL,
21
- provider_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
22
- external_id text,
23
- config jsonb NOT NULL DEFAULT '{}'::jsonb,
24
- created_by uuid REFERENCES users(id),
25
- updated_by uuid REFERENCES users(id),
26
- created_at timestamptz NOT NULL DEFAULT now(),
27
- updated_at timestamptz NOT NULL DEFAULT now(),
28
- UNIQUE(project_id, environment, resource_type)
29
- );
30
-
31
- CREATE INDEX IF NOT EXISTS project_resources_project_idx
32
- ON project_resources(project_id, environment, resource_type);
33
-
34
- CREATE INDEX IF NOT EXISTS project_resources_provider_user_idx
35
- ON project_resources(provider_user_id, provider);
36
-
37
- -- provider credentials belong to an authenticated IndraQ user and are
38
- -- reusable across every project that user can access. Project resources keep
39
- -- only infrastructure associations plus the user whose provider profile last
40
- -- reconciled the resource.
1
+ CREATE TABLE IF NOT EXISTS user_integrations (
2
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4
+ provider text NOT NULL,
5
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
6
+ encrypted_secret text,
7
+ created_at timestamptz NOT NULL DEFAULT now(),
8
+ updated_at timestamptz NOT NULL DEFAULT now(),
9
+ UNIQUE(user_id, provider)
10
+ );
11
+
12
+ CREATE INDEX IF NOT EXISTS user_integrations_user_idx
13
+ ON user_integrations(user_id, provider);
14
+
15
+ CREATE TABLE IF NOT EXISTS project_resources (
16
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
17
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
18
+ environment text NOT NULL DEFAULT 'dev',
19
+ resource_type text NOT NULL,
20
+ provider text NOT NULL,
21
+ provider_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
22
+ external_id text,
23
+ config jsonb NOT NULL DEFAULT '{}'::jsonb,
24
+ created_by uuid REFERENCES users(id),
25
+ updated_by uuid REFERENCES users(id),
26
+ created_at timestamptz NOT NULL DEFAULT now(),
27
+ updated_at timestamptz NOT NULL DEFAULT now(),
28
+ UNIQUE(project_id, environment, resource_type)
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS project_resources_project_idx
32
+ ON project_resources(project_id, environment, resource_type);
33
+
34
+ CREATE INDEX IF NOT EXISTS project_resources_provider_user_idx
35
+ ON project_resources(provider_user_id, provider);
36
+
37
+ -- provider credentials belong to an authenticated IndraQ user and are
38
+ -- reusable across every project that user can access. Project resources keep
39
+ -- only infrastructure associations plus the user whose provider profile last
40
+ -- reconciled the resource.
@@ -1,19 +1,19 @@
1
- -- shared admin-managed named environments.
2
- -- Environments are generic reusable targets. PRIMARY/SECONDARY is assigned only
3
- -- when a project creates a FAILOVER Route53 record.
4
- CREATE TABLE IF NOT EXISTS environments (
5
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6
- name text NOT NULL UNIQUE,
7
- description text,
8
- ipv4 inet NOT NULL,
9
- region text,
10
- health_path text NOT NULL DEFAULT '/health',
11
- created_by uuid NOT NULL REFERENCES users(id),
12
- created_at timestamptz NOT NULL DEFAULT now(),
13
- updated_at timestamptz NOT NULL DEFAULT now()
14
- );
15
-
16
- CREATE INDEX IF NOT EXISTS environments_name_idx ON environments(name);
17
-
18
- ALTER TABLE project_resources
19
- ADD COLUMN IF NOT EXISTS environment_id uuid REFERENCES environments(id) ON DELETE SET NULL;
1
+ -- shared admin-managed named environments.
2
+ -- Environments are generic reusable targets. PRIMARY/SECONDARY is assigned only
3
+ -- when a project creates a FAILOVER Route53 record.
4
+ CREATE TABLE IF NOT EXISTS environments (
5
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6
+ name text NOT NULL UNIQUE,
7
+ description text,
8
+ ipv4 inet NOT NULL,
9
+ region text,
10
+ health_path text NOT NULL DEFAULT '/health',
11
+ created_by uuid NOT NULL REFERENCES users(id),
12
+ created_at timestamptz NOT NULL DEFAULT now(),
13
+ updated_at timestamptz NOT NULL DEFAULT now()
14
+ );
15
+
16
+ CREATE INDEX IF NOT EXISTS environments_name_idx ON environments(name);
17
+
18
+ ALTER TABLE project_resources
19
+ ADD COLUMN IF NOT EXISTS environment_id uuid REFERENCES environments(id) ON DELETE SET NULL;