credentials.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  1. """
  2. AWS Credentials Management API endpoints
  3. Provides credential CRUD operations and validation.
  4. Requirements: 2.1, 2.4, 2.6, 2.7
  5. """
  6. from flask import jsonify, request, g
  7. from app import db
  8. from app.api import api_bp
  9. from app.models import AWSCredential, UserCredential, BaseAssumeRoleConfig
  10. from app.services import login_required, admin_required, get_current_user_from_context, get_accessible_credentials
  11. from app.errors import ValidationError, NotFoundError
  12. from app.scanners.credentials import AWSCredentialProvider, CredentialError
  13. def validate_account_id(account_id: str) -> bool:
  14. """Validate AWS account ID format (12 digits)"""
  15. if not account_id:
  16. return False
  17. return len(account_id) == 12 and account_id.isdigit()
  18. def validate_role_arn(role_arn: str) -> bool:
  19. """Validate AWS Role ARN format"""
  20. if not role_arn:
  21. return False
  22. # Basic ARN format: arn:aws:iam::account-id:role/role-name
  23. return role_arn.startswith('arn:aws:iam::') and ':role/' in role_arn
  24. @api_bp.route('/credentials', methods=['GET'])
  25. @login_required
  26. def get_credentials():
  27. """
  28. Get credentials list with pagination (sensitive info masked)
  29. Query params:
  30. page: Page number (default: 1)
  31. page_size: Items per page (default: 20, max: 100)
  32. Returns:
  33. {
  34. "data": [credential objects with masked sensitive data],
  35. "pagination": {...}
  36. }
  37. """
  38. # Get pagination parameters
  39. page = request.args.get('page', 1, type=int)
  40. # Support both pageSize (frontend) and page_size (backend convention)
  41. page_size = request.args.get('pageSize', type=int) or request.args.get('page_size', type=int) or 20
  42. # Validate pagination parameters
  43. if page < 1:
  44. page = 1
  45. if page_size < 1:
  46. page_size = 20
  47. if page_size > 100:
  48. page_size = 100
  49. # Get current user
  50. current_user = get_current_user_from_context()
  51. # Get accessible credentials based on user role
  52. query = get_accessible_credentials(current_user)
  53. # Order by created_at descending
  54. query = query.order_by(AWSCredential.created_at.desc())
  55. # Get total count
  56. total = query.count()
  57. # Calculate total pages
  58. total_pages = (total + page_size - 1) // page_size if total > 0 else 1
  59. # Apply pagination
  60. credentials = query.offset((page - 1) * page_size).limit(page_size).all()
  61. return jsonify({
  62. 'data': [cred.to_dict(mask_sensitive=True) for cred in credentials],
  63. 'pagination': {
  64. 'page': page,
  65. 'page_size': page_size,
  66. 'total': total,
  67. 'total_pages': total_pages
  68. }
  69. }), 200
  70. @api_bp.route('/credentials/create', methods=['POST'])
  71. @admin_required
  72. def create_credential():
  73. """
  74. Create a new AWS credential
  75. Request body:
  76. {
  77. "name": "string" (required),
  78. "credential_type": "assume_role" | "access_key" (required),
  79. "account_id": "string" (required, 12 digits),
  80. "role_arn": "string" (required for assume_role),
  81. "external_id": "string" (optional for assume_role),
  82. "access_key_id": "string" (required for access_key),
  83. "secret_access_key": "string" (required for access_key)
  84. }
  85. Returns:
  86. { credential object }
  87. """
  88. data = request.get_json()
  89. if not data:
  90. raise ValidationError(
  91. message="Request body is required",
  92. details={"reason": "missing_body"}
  93. )
  94. # Validate required fields
  95. required_fields = ['name', 'credential_type']
  96. if data.get('credential_type') == 'assume_role':
  97. required_fields.append('account_id')
  98. missing_fields = [field for field in required_fields if not data.get(field)]
  99. if missing_fields:
  100. raise ValidationError(
  101. message="Missing required fields",
  102. details={"missing_fields": missing_fields}
  103. )
  104. name = data['name'].strip()
  105. credential_type = data['credential_type']
  106. account_id = data.get('account_id', '').strip() if data.get('account_id') else None
  107. # Validate name length
  108. if len(name) < 1 or len(name) > 100:
  109. raise ValidationError(
  110. message="Name must be between 1 and 100 characters",
  111. details={"field": "name", "reason": "invalid_length"}
  112. )
  113. # Validate credential type
  114. if credential_type not in ['assume_role', 'access_key']:
  115. raise ValidationError(
  116. message="Invalid credential type. Must be 'assume_role' or 'access_key'",
  117. details={"field": "credential_type", "reason": "invalid_value"}
  118. )
  119. # Validate account ID (only required for assume_role)
  120. if credential_type == 'assume_role':
  121. if not account_id or not validate_account_id(account_id):
  122. raise ValidationError(
  123. message="Invalid AWS account ID. Must be 12 digits",
  124. details={"field": "account_id", "reason": "invalid_format"}
  125. )
  126. # Validate type-specific fields
  127. if credential_type == 'assume_role':
  128. role_arn = data.get('role_arn', '').strip()
  129. if not role_arn:
  130. raise ValidationError(
  131. message="Role ARN is required for assume_role credential type",
  132. details={"missing_fields": ["role_arn"]}
  133. )
  134. if not validate_role_arn(role_arn):
  135. raise ValidationError(
  136. message="Invalid Role ARN format",
  137. details={"field": "role_arn", "reason": "invalid_format"}
  138. )
  139. else: # access_key
  140. access_key_id = data.get('access_key_id', '').strip()
  141. secret_access_key = data.get('secret_access_key', '').strip()
  142. if not access_key_id:
  143. raise ValidationError(
  144. message="Access Key ID is required for access_key credential type",
  145. details={"missing_fields": ["access_key_id"]}
  146. )
  147. if not secret_access_key:
  148. raise ValidationError(
  149. message="Secret Access Key is required for access_key credential type",
  150. details={"missing_fields": ["secret_access_key"]}
  151. )
  152. # Create credential
  153. credential = AWSCredential(
  154. name=name,
  155. credential_type=credential_type,
  156. account_id=account_id, # Will be None for access_key initially
  157. is_active=True
  158. )
  159. if credential_type == 'assume_role':
  160. credential.role_arn = data.get('role_arn', '').strip()
  161. credential.external_id = data.get('external_id', '').strip() or None
  162. else:
  163. access_key_id = data.get('access_key_id', '').strip()
  164. secret_access_key = data.get('secret_access_key', '').strip()
  165. credential.access_key_id = access_key_id
  166. credential.set_secret_access_key(secret_access_key)
  167. # Auto-detect account ID for access_key type
  168. if not account_id:
  169. try:
  170. provider = AWSCredentialProvider(
  171. credential_type='access_key',
  172. credential_config={
  173. 'access_key_id': access_key_id,
  174. 'secret_access_key': secret_access_key
  175. }
  176. )
  177. provider.validate()
  178. detected_account_id = provider.get_account_id()
  179. credential.account_id = detected_account_id
  180. except Exception as e:
  181. raise ValidationError(
  182. message=f"Failed to validate Access Key credentials: {str(e)}",
  183. details={"reason": "credential_validation_failed"}
  184. )
  185. db.session.add(credential)
  186. db.session.commit()
  187. return jsonify(credential.to_dict(mask_sensitive=True)), 201
  188. @api_bp.route('/credentials/update', methods=['POST'])
  189. @admin_required
  190. def update_credential():
  191. """
  192. Update an existing AWS credential
  193. Request body:
  194. {
  195. "id": number (required),
  196. "name": "string" (optional),
  197. "account_id": "string" (optional),
  198. "role_arn": "string" (optional, for assume_role),
  199. "external_id": "string" (optional, for assume_role),
  200. "access_key_id": "string" (optional, for access_key),
  201. "secret_access_key": "string" (optional, for access_key),
  202. "is_active": boolean (optional)
  203. }
  204. Returns:
  205. { updated credential object }
  206. """
  207. data = request.get_json()
  208. if not data:
  209. raise ValidationError(
  210. message="Request body is required",
  211. details={"reason": "missing_body"}
  212. )
  213. # Validate credential ID
  214. credential_id = data.get('id')
  215. if not credential_id:
  216. raise ValidationError(
  217. message="Credential ID is required",
  218. details={"missing_fields": ["id"]}
  219. )
  220. # Find credential
  221. credential = db.session.get(AWSCredential, credential_id)
  222. if not credential:
  223. raise NotFoundError(
  224. message="Credential not found",
  225. details={"credential_id": credential_id}
  226. )
  227. # Update name if provided
  228. if 'name' in data and data['name']:
  229. new_name = data['name'].strip()
  230. if len(new_name) < 1 or len(new_name) > 100:
  231. raise ValidationError(
  232. message="Name must be between 1 and 100 characters",
  233. details={"field": "name", "reason": "invalid_length"}
  234. )
  235. credential.name = new_name
  236. # Update account_id if provided
  237. if 'account_id' in data and data['account_id']:
  238. new_account_id = data['account_id'].strip()
  239. if not validate_account_id(new_account_id):
  240. raise ValidationError(
  241. message="Invalid AWS account ID. Must be 12 digits",
  242. details={"field": "account_id", "reason": "invalid_format"}
  243. )
  244. credential.account_id = new_account_id
  245. # Update type-specific fields
  246. if credential.credential_type == 'assume_role':
  247. if 'role_arn' in data and data['role_arn']:
  248. new_role_arn = data['role_arn'].strip()
  249. if not validate_role_arn(new_role_arn):
  250. raise ValidationError(
  251. message="Invalid Role ARN format",
  252. details={"field": "role_arn", "reason": "invalid_format"}
  253. )
  254. credential.role_arn = new_role_arn
  255. if 'external_id' in data:
  256. credential.external_id = data['external_id'].strip() if data['external_id'] else None
  257. else: # access_key
  258. if 'access_key_id' in data and data['access_key_id']:
  259. credential.access_key_id = data['access_key_id'].strip()
  260. if 'secret_access_key' in data and data['secret_access_key']:
  261. credential.set_secret_access_key(data['secret_access_key'].strip())
  262. # Update is_active if provided
  263. if 'is_active' in data:
  264. credential.is_active = bool(data['is_active'])
  265. db.session.commit()
  266. return jsonify(credential.to_dict(mask_sensitive=True)), 200
  267. @api_bp.route('/credentials/delete', methods=['POST'])
  268. @admin_required
  269. def delete_credential():
  270. """
  271. Delete an AWS credential
  272. Request body:
  273. {
  274. "id": number (required)
  275. }
  276. Returns:
  277. { "message": "Credential deleted successfully" }
  278. """
  279. data = request.get_json()
  280. if not data:
  281. raise ValidationError(
  282. message="Request body is required",
  283. details={"reason": "missing_body"}
  284. )
  285. # Validate credential ID
  286. credential_id = data.get('id')
  287. if not credential_id:
  288. raise ValidationError(
  289. message="Credential ID is required",
  290. details={"missing_fields": ["id"]}
  291. )
  292. # Find credential
  293. credential = db.session.get(AWSCredential, credential_id)
  294. if not credential:
  295. raise NotFoundError(
  296. message="Credential not found",
  297. details={"credential_id": credential_id}
  298. )
  299. # Delete credential (cascade will handle user assignments)
  300. db.session.delete(credential)
  301. db.session.commit()
  302. return jsonify({
  303. 'message': 'Credential deleted successfully'
  304. }), 200
  305. @api_bp.route('/credentials/validate', methods=['POST'])
  306. @admin_required
  307. def validate_credential():
  308. """
  309. Validate an AWS credential by testing connection to AWS
  310. Request body:
  311. {
  312. "id": number (required) - existing credential ID
  313. }
  314. OR
  315. {
  316. "credential_type": "assume_role" | "access_key" (required),
  317. "role_arn": "string" (required for assume_role),
  318. "external_id": "string" (optional for assume_role),
  319. "access_key_id": "string" (required for access_key),
  320. "secret_access_key": "string" (required for access_key)
  321. }
  322. Returns:
  323. { "valid": boolean, "account_id": "string" (if valid), "error": "string" (if invalid) }
  324. """
  325. data = request.get_json()
  326. if not data:
  327. raise ValidationError(
  328. message="Request body is required",
  329. details={"reason": "missing_body"}
  330. )
  331. credential_config = {}
  332. base_credentials = None
  333. credential_type = None
  334. # Check if validating existing credential by ID
  335. if 'id' in data:
  336. credential_id = data['id']
  337. credential = db.session.get(AWSCredential, credential_id)
  338. if not credential:
  339. raise NotFoundError(
  340. message="Credential not found",
  341. details={"credential_id": credential_id}
  342. )
  343. credential_type = credential.credential_type
  344. if credential_type == 'assume_role':
  345. credential_config = {
  346. 'role_arn': credential.role_arn,
  347. 'external_id': credential.external_id
  348. }
  349. # Get base credentials for assume role
  350. base_config = BaseAssumeRoleConfig.query.first()
  351. if not base_config:
  352. raise ValidationError(
  353. message="Base Assume Role configuration not found. Please configure it first.",
  354. details={"reason": "missing_base_config"}
  355. )
  356. base_credentials = {
  357. 'access_key_id': base_config.access_key_id,
  358. 'secret_access_key': base_config.get_secret_access_key()
  359. }
  360. # Add session token if available
  361. session_token = base_config.get_session_token()
  362. if session_token:
  363. base_credentials['session_token'] = session_token
  364. else:
  365. credential_config = {
  366. 'access_key_id': credential.access_key_id,
  367. 'secret_access_key': credential.get_secret_access_key()
  368. }
  369. else:
  370. # Validating new credential data
  371. credential_type = data.get('credential_type')
  372. if not credential_type:
  373. raise ValidationError(
  374. message="Either 'id' or 'credential_type' is required",
  375. details={"reason": "missing_identifier"}
  376. )
  377. if credential_type == 'assume_role':
  378. role_arn = data.get('role_arn', '').strip()
  379. if not role_arn:
  380. raise ValidationError(
  381. message="Role ARN is required for assume_role validation",
  382. details={"missing_fields": ["role_arn"]}
  383. )
  384. credential_config = {
  385. 'role_arn': role_arn,
  386. 'external_id': data.get('external_id', '').strip() or None
  387. }
  388. # Get base credentials for assume role
  389. base_config = BaseAssumeRoleConfig.query.first()
  390. if not base_config:
  391. raise ValidationError(
  392. message="Base Assume Role configuration not found. Please configure it first.",
  393. details={"reason": "missing_base_config"}
  394. )
  395. base_credentials = {
  396. 'access_key_id': base_config.access_key_id,
  397. 'secret_access_key': base_config.get_secret_access_key()
  398. }
  399. # Add session token if available
  400. session_token = base_config.get_session_token()
  401. if session_token:
  402. base_credentials['session_token'] = session_token
  403. elif credential_type == 'access_key':
  404. access_key_id = data.get('access_key_id', '').strip()
  405. secret_access_key = data.get('secret_access_key', '').strip()
  406. if not access_key_id or not secret_access_key:
  407. raise ValidationError(
  408. message="Access Key ID and Secret Access Key are required",
  409. details={"missing_fields": ["access_key_id", "secret_access_key"]}
  410. )
  411. credential_config = {
  412. 'access_key_id': access_key_id,
  413. 'secret_access_key': secret_access_key
  414. }
  415. else:
  416. raise ValidationError(
  417. message="Invalid credential type",
  418. details={"field": "credential_type", "reason": "invalid_value"}
  419. )
  420. # Validate the credential
  421. try:
  422. provider = AWSCredentialProvider(
  423. credential_type=credential_type,
  424. credential_config=credential_config,
  425. base_credentials=base_credentials
  426. )
  427. provider.validate()
  428. account_id = provider.get_account_id()
  429. return jsonify({
  430. 'valid': True,
  431. 'account_id': account_id
  432. }), 200
  433. except CredentialError as e:
  434. return jsonify({
  435. 'valid': False,
  436. 'error': str(e)
  437. }), 200
  438. except Exception as e:
  439. return jsonify({
  440. 'valid': False,
  441. 'error': f"Validation failed: {str(e)}"
  442. }), 200
  443. @api_bp.route('/credentials/base-role', methods=['GET'])
  444. @admin_required
  445. def get_base_role():
  446. """
  447. Get base Assume Role configuration
  448. Returns:
  449. { base role config object with masked sensitive data }
  450. OR
  451. { "configured": false } if not configured
  452. """
  453. config = BaseAssumeRoleConfig.query.first()
  454. if not config:
  455. return jsonify({
  456. 'configured': False
  457. }), 200
  458. return jsonify({
  459. 'configured': True,
  460. 'data': config.to_dict(mask_sensitive=True)
  461. }), 200
  462. @api_bp.route('/credentials/base-role', methods=['POST'])
  463. @admin_required
  464. def update_base_role():
  465. """
  466. Update base Assume Role configuration
  467. Request body:
  468. {
  469. "access_key_id": "string" (required),
  470. "secret_access_key": "string" (required),
  471. "session_token": "string" (optional, for temporary credentials)
  472. }
  473. Returns:
  474. { base role config object }
  475. """
  476. data = request.get_json()
  477. if not data:
  478. raise ValidationError(
  479. message="Request body is required",
  480. details={"reason": "missing_body"}
  481. )
  482. # Validate required fields
  483. access_key_id = data.get('access_key_id', '').strip()
  484. secret_access_key = data.get('secret_access_key', '').strip()
  485. session_token = data.get('session_token', '').strip() if data.get('session_token') else None
  486. if not access_key_id:
  487. raise ValidationError(
  488. message="Access Key ID is required",
  489. details={"missing_fields": ["access_key_id"]}
  490. )
  491. if not secret_access_key:
  492. raise ValidationError(
  493. message="Secret Access Key is required",
  494. details={"missing_fields": ["secret_access_key"]}
  495. )
  496. # Validate the credentials before saving
  497. try:
  498. credential_config = {
  499. 'access_key_id': access_key_id,
  500. 'secret_access_key': secret_access_key
  501. }
  502. if session_token:
  503. credential_config['session_token'] = session_token
  504. provider = AWSCredentialProvider(
  505. credential_type='access_key',
  506. credential_config=credential_config
  507. )
  508. provider.validate()
  509. except CredentialError as e:
  510. raise ValidationError(
  511. message=f"Invalid credentials: {str(e)}",
  512. details={"reason": "validation_failed"}
  513. )
  514. except Exception as e:
  515. raise ValidationError(
  516. message=f"Credential validation failed: {str(e)}",
  517. details={"reason": "validation_error"}
  518. )
  519. # Get or create config
  520. config = BaseAssumeRoleConfig.query.first()
  521. if config:
  522. # Update existing config
  523. config.access_key_id = access_key_id
  524. config.set_secret_access_key(secret_access_key)
  525. config.set_session_token(session_token)
  526. else:
  527. # Create new config
  528. config = BaseAssumeRoleConfig(
  529. access_key_id=access_key_id
  530. )
  531. config.set_secret_access_key(secret_access_key)
  532. config.set_session_token(session_token)
  533. db.session.add(config)
  534. db.session.commit()
  535. return jsonify({
  536. 'message': 'Base Assume Role configuration updated successfully',
  537. 'data': config.to_dict(mask_sensitive=True)
  538. }), 200