Skip to content

Commit 156fa86

Browse files
authored
GitOps Account - improved API access verification (#536)
* GitOps Account - improved API access verification * chore: removed unnecessary check for path * chore: fixes test fixture
1 parent 279659e commit 156fa86

File tree

8 files changed

+376
-262
lines changed

8 files changed

+376
-262
lines changed

npm-shrinkwrap.json

Lines changed: 178 additions & 238 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@
4949
"helmet": "^8.0.0",
5050
"jsonwebtoken": "^9.0.2",
5151
"moment": "^2.30.1",
52-
"mongodb": "^6.9.0",
53-
"mongoose": "^8.7.2",
52+
"mongodb": "^6.10.0",
53+
"mongoose": "^8.7.3",
5454
"pino": "^9.5.0",
5555
"pino-pretty": "^11.3.0",
5656
"swagger-ui-express": "^5.0.1",

src/external/switcher-api-facade.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const SwitcherKeys = Object.freeze({
2727
ACCOUNT_OUT_NOTIFY: 'ACCOUNT_OUT_NOTIFY',
2828
SLACK_INTEGRATION: 'SLACK_INTEGRATION',
2929
GITOPS_INTEGRATION: 'GITOPS_INTEGRATION',
30+
GITOPS_SUBSCRIPTION: 'GITOPS_SUBSCRIPTION',
3031
RATE_LIMIT: 'RATE_LIMIT',
3132
HTTPS_AGENT: 'HTTPS_AGENT'
3233
});
@@ -214,6 +215,16 @@ export async function checkGitopsIntegration(value) {
214215
switcherFlagResult(featureFlag, 'GitOps Integration is not available.');
215216
}
216217

218+
export function notifyGitopsSubscription(action) {
219+
if (process.env.SWITCHER_API_ENABLE != 'true') {
220+
return;
221+
}
222+
223+
Client.getSwitcher(SwitcherKeys.GITOPS_SUBSCRIPTION)
224+
.checkValue(action)
225+
.isItOn();
226+
}
227+
217228
export function notifyAcCreation(adminid) {
218229
if (process.env.SWITCHER_API_ENABLE != 'true') {
219230
return;

src/routers/gitops.js

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import { responseExceptionSilent } from '../exceptions/index.js';
44
import { auth, gitopsAuth } from '../middleware/auth.js';
55
import { validate } from '../middleware/validators.js';
66
import { featureFlag, validateChanges } from '../middleware/gitops.js';
7+
import { notifyGitopsSubscription } from '../external/switcher-api-facade.js';
78
import * as Service from '../services/gitops/index.js';
9+
import { verifyOwnership } from '../helpers/index.js';
10+
import { ActionTypes, RouterTypes } from '../models/permission.js';
811

912
const router = new express.Router();
10-
const regex = new RegExp(/^\d+[smh]$/);
13+
14+
// Allow only values like 1s, 1m, 1h
15+
const regexWindowInterval = new RegExp(/^\d+[smh]$/);
16+
17+
// Allow slash, alphanumeric, hyphen, underscore, dot only
18+
const regexPath = new RegExp(/^[a-zA-Z0-9/_\-.]+$/);
1119

1220
const windowValidation = (value) => {
13-
if (!regex.test(value)) {
21+
if (!regexWindowInterval.test(value)) {
1422
throw new Error('Invalid window value');
1523
}
1624

@@ -25,11 +33,23 @@ const windowValidation = (value) => {
2533
return true;
2634
};
2735

36+
const pathValidation = (value) => {
37+
if (value.startsWith('/') || value.endsWith('/') || value.includes('//')) {
38+
throw new Error('Invalid path value - cannot start or end with / or contain //');
39+
}
40+
41+
if (!regexPath.test(value)) {
42+
throw new Error('Invalid path value - only alphanumeric characters and / are allowed');
43+
}
44+
45+
return true;
46+
};
47+
2848
const accountValidators = [
2949
body('token').isString().optional(),
3050
body('repository').isURL().withMessage('Invalid repository URL'),
3151
body('branch').isString().withMessage('Invalid branch name'),
32-
body('path').isString().optional().withMessage('Invalid path'),
52+
body('path').isString().optional().custom(pathValidation),
3353
body('environment').isString().withMessage('Invalid environment name'),
3454
body('domain.id').isMongoId().withMessage('Invalid domain ID'),
3555
body('domain.name').isString().withMessage('Invalid domain name'),
@@ -38,6 +58,16 @@ const accountValidators = [
3858
body('settings.forceprune').isBoolean().withMessage('Invalid forceprune flag'),
3959
];
4060

61+
const verifyOwnershipMiddleware = async (req, res, next) => {
62+
try {
63+
const domainId = req.body?.domain.id || req.params.domain;
64+
await verifyOwnership(req.admin, domainId, domainId, ActionTypes.UPDATE, RouterTypes.ADMIN);
65+
next();
66+
} catch (e) {
67+
responseExceptionSilent(res, e, 403, 'Permission denied');
68+
}
69+
};
70+
4171
router.post('/gitops/v1/push', gitopsAuth, featureFlag, [
4272
body('environment').isString(),
4373
body('changes').isArray(),
@@ -58,9 +88,11 @@ router.post('/gitops/v1/push', gitopsAuth, featureFlag, [
5888
});
5989

6090
router.post('/gitops/v1/account/subscribe', auth, accountValidators, validate,
61-
featureFlag, async (req, res) => {
91+
featureFlag, verifyOwnershipMiddleware, async (req, res) => {
6292
try {
6393
const account = await Service.subscribeAccount(req.body);
94+
notifyGitopsSubscription('subscribe');
95+
6496
res.status(201).send(account);
6597
} catch (e) {
6698
responseExceptionSilent(res, e, 500, 'Account subscription failed');
@@ -70,17 +102,19 @@ router.post('/gitops/v1/account/subscribe', auth, accountValidators, validate,
70102
router.post('/gitops/v1/account/unsubscribe', auth, [
71103
body('environment').isString(),
72104
body('domain.id').isMongoId().withMessage('Invalid domain ID'),
73-
], validate, featureFlag, async (req, res) => {
105+
], validate, featureFlag, verifyOwnershipMiddleware, async (req, res) => {
74106
try {
75107
await Service.unsubscribeAccount(req.body);
108+
notifyGitopsSubscription('unsubscribe');
109+
76110
res.status(200).send();
77111
} catch (e) {
78112
responseExceptionSilent(res, e, 500, 'Account unsubscription failed');
79113
}
80114
});
81115

82116
router.put('/gitops/v1/account', auth, accountValidators, validate,
83-
featureFlag, async (req, res) => {
117+
featureFlag, verifyOwnershipMiddleware, async (req, res) => {
84118
try {
85119
const account = await Service.updateAccount(req.body);
86120
res.status(200).send(account);
@@ -93,10 +127,10 @@ router.put('/gitops/v1/account/tokens', auth, [
93127
body('token').isString(),
94128
body('environments').isArray(),
95129
body('domain.id').isMongoId().withMessage('Invalid domain ID'),
96-
], validate, featureFlag, async (req, res) => {
130+
], validate, featureFlag, verifyOwnershipMiddleware, async (req, res) => {
97131
try {
98-
const account = await Service.updateAccountTokens(req.body);
99-
res.status(200).send(account);
132+
const result = await Service.updateAccountTokens(req.body);
133+
res.status(200).send(result);
100134
} catch (e) {
101135
responseExceptionSilent(res, e, 500, 'Account token update failed');
102136
}
@@ -105,7 +139,7 @@ router.put('/gitops/v1/account/tokens', auth, [
105139
router.put('/gitops/v1/account/forcesync', auth, [
106140
body('environment').isString(),
107141
body('domain.id').isMongoId().withMessage('Invalid domain ID'),
108-
], validate, featureFlag, async (req, res) => {
142+
], validate, featureFlag, verifyOwnershipMiddleware, async (req, res) => {
109143
try {
110144
const account = await Service.forceSyncAccount(req.body);
111145
res.status(200).send(account);
@@ -117,7 +151,7 @@ router.put('/gitops/v1/account/forcesync', auth, [
117151
router.get('/gitops/v1/account/:domain', auth, [
118152
check('domain').isMongoId().withMessage('Invalid domain ID'),
119153
check('environment').optional().isString(),
120-
], validate, featureFlag, async (req, res) => {
154+
], validate, featureFlag, verifyOwnershipMiddleware, async (req, res) => {
121155
try {
122156
const accounts = await Service.fetchAccounts(req.params.domain, req.query.environment || null);
123157
res.status(200).send(accounts);

src/services/gitops/index.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,33 @@ export async function pushChanges(domainId, environment, changes) {
3030
}
3131

3232
export async function subscribeAccount(account) {
33-
return GitOpsFacade.createAccount(account);
33+
return GitOpsFacade.createAccount({
34+
repository: account.repository,
35+
environment: account.environment,
36+
branch: account.branch,
37+
token: account.token,
38+
path: account.path,
39+
settings: account.settings,
40+
domain: {
41+
id: account.domain.id,
42+
name: account.domain.name
43+
}
44+
});
3445
}
3546

3647
export async function updateAccount(account) {
37-
return GitOpsFacade.updateAccount(account);
48+
return GitOpsFacade.updateAccount({
49+
repository: account.repository,
50+
environment: account.environment,
51+
branch: account.branch,
52+
token: account.token,
53+
path: account.path,
54+
settings: account.settings,
55+
domain: {
56+
id: account.domain.id,
57+
name: account.domain.name
58+
}
59+
});
3860
}
3961

4062
export async function updateAccountTokens(account) {

tests/client-api.test.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ describe('Testing domain [Adm-GraphQL] ', () => {
262262
expect(req.statusCode).toBe(200);
263263
expect(JSON.parse(req.text)).toMatchObject(JSON.parse(graphqlUtils.expected111));
264264
});
265+
});
266+
267+
describe('Testing domain [Adm-GraphQL] - Permission', () => {
268+
269+
afterAll(setupDatabase);
265270

266271
test('CLIENT_SUITE - Should return list of Groups permissions', async () => {
267272
const req = await request(app)
@@ -366,11 +371,6 @@ describe('Testing domain [Adm-GraphQL] ', () => {
366371
expect(JSON.parse(req.text)).not.toBe(null);
367372
expect(JSON.parse(req.text).data.permission).toStrictEqual([]);
368373
});
369-
});
370-
371-
describe('Testing domain [Adm-GraphQL] - Permission', () => {
372-
373-
afterAll(setupDatabase);
374374

375375
test('CLIENT_SUITE - Should return domain partial structure based on permission', async () => {
376376
// Given
@@ -393,7 +393,7 @@ describe('Testing domain [Adm-GraphQL] - Permission', () => {
393393
expect(JSON.parse(req.text)).toMatchObject(JSON.parse(graphqlUtils.expected1071));
394394
});
395395

396-
test('CLIENT_SUITE - Should NOT return complete domain structure - no valid COnfig permission', async () => {
396+
test('CLIENT_SUITE - Should NOT return complete domain structure - no valid Config permission', async () => {
397397
// Given
398398
const admin = await Admin.findById(adminAccountId).exec();
399399
await setPermissionsToTeam(admin.teams[0], {

tests/fixtures/db_client.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,21 @@ export const permissionConfigs2 = {
182182
environments: [EnvType.DEFAULT]
183183
};
184184

185+
export const permissionAdminId = new mongoose.Types.ObjectId();
186+
export const permissionAdmin = {
187+
_id: permissionAdminId,
188+
action: ActionTypes.UPDATE,
189+
active: true,
190+
router: RouterTypes.ADMIN
191+
};
192+
185193
export const teamId = new mongoose.Types.ObjectId();
186194
export const team = {
187195
_id: teamId,
188196
domain: domainId,
189197
name: 'Team Dev',
190198
active: true,
191-
permissions: [permissionConfigsId, permissionConfigs2Id]
199+
permissions: [permissionConfigsId, permissionConfigs2Id, permissionAdminId]
192200
};
193201

194202
export const slack = {
@@ -229,6 +237,7 @@ export const setupDatabase = async () => {
229237
await new Team(team).save();
230238
await new Permission(permissionConfigs).save();
231239
await new Permission(permissionConfigs2).save();
240+
await new Permission(permissionAdmin).save();
232241

233242
await new GroupConfig(groupConfigDocument).save();
234243
await new Config(configDocument).save();

0 commit comments

Comments
 (0)