This document covers the authentication and authorization mechanisms used by the FinOps Cloud Resource Cleanup API.
The FinOps API uses a multi-layered security approach combining AWS IAM, API Gateway authentication, and custom authorization logic to ensure secure access to resources and operations.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/FinOpsApplicationRole"
},
"Action": "sts:AssumeRole"
}
]
}# Example: Python client with AWS SigV4
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
import requests
def make_authenticated_request(url, method='GET', payload=None):
"""Make authenticated request using AWS SigV4."""
session = boto3.Session()
credentials = session.get_credentials()
region = session.region_name
request = AWSRequest(
method=method,
url=url,
data=payload,
headers={'Content-Type': 'application/json'}
)
SigV4Auth(credentials, 'execute-api', region).add_auth(request)
return requests.request(
method=method,
url=url,
headers=dict(request.headers),
data=payload
)
# Usage
response = make_authenticated_request(
'https://api.finops.company.com/resources',
method='GET'
)# Create API key via AWS CLI
aws apigateway create-api-key \
--name "finops-client-key" \
--description "API key for FinOps client applications" \
--enabled
# Associate with usage plan
aws apigateway create-usage-plan-key \
--usage-plan-id "abcdef123456" \
--key-id "1234567890abcdef" \
--key-type "API_KEY"# Python example
import requests
headers = {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
}
response = requests.get(
'https://api.finops.company.com/resources',
headers=headers
)// JavaScript example
const response = await fetch("https://api.finops.company.com/resources", {
headers: {
"X-API-Key": "your-api-key-here",
"Content-Type": "application/json",
},
});# curl example
curl -X GET \
https://api.finops.company.com/resources \
-H 'X-API-Key: your-api-key-here' \
-H 'Content-Type: application/json'{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "finops-key-id"
},
"payload": {
"sub": "user@company.com",
"iss": "finops-auth-service",
"aud": "finops-api",
"exp": 1706443200,
"iat": 1706356800,
"roles": ["finops:viewer", "finops:operator"],
"permissions": ["resources:read", "reports:read", "scans:execute"],
"account_id": "123456789012",
"departments": ["engineering", "finance"]
}
}# Lambda authorizer for JWT validation
import jwt
import json
import os
from typing import Dict, Any
def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]:
"""JWT token validation Lambda authorizer."""
token = extract_token(event)
try:
# Validate token
decoded_token = jwt.decode(
token,
key=get_public_key(),
algorithms=['RS256'],
audience='finops-api',
issuer='finops-auth-service'
)
# Generate policy
policy = generate_policy(
principal_id=decoded_token['sub'],
effect='Allow',
resource=event['methodArn'],
context=decoded_token
)
return policy
except jwt.InvalidTokenError as e:
raise Exception('Unauthorized')
def extract_token(event: Dict[str, Any]) -> str:
"""Extract JWT token from Authorization header."""
auth_header = event.get('authorizationToken', '')
if not auth_header.startswith('Bearer '):
raise Exception('Invalid token format')
return auth_header[7:] # Remove 'Bearer ' prefix
def get_public_key() -> str:
"""Get public key for JWT verification."""
# In production, fetch from JWKS endpoint or AWS Secrets Manager
return os.environ.get('JWT_PUBLIC_KEY')
def generate_policy(principal_id: str, effect: str, resource: str, context: Dict[str, Any]) -> Dict[str, Any]:
"""Generate IAM policy for API Gateway."""
return {
'principalId': principal_id,
'policyDocument': {
'Version': '2012-10-17',
'Statement': [
{
'Action': 'execute-api:Invoke',
'Effect': effect,
'Resource': resource
}
]
},
'context': {
'user_id': context.get('sub'),
'roles': ','.join(context.get('roles', [])),
'permissions': ','.join(context.get('permissions', [])),
'account_id': context.get('account_id')
}
}# Role and permission definitions
ROLES = {
'finops:admin': {
'description': 'Full administrative access',
'permissions': [
'resources:*',
'reports:*',
'scans:*',
'settings:*',
'users:*'
]
},
'finops:operator': {
'description': 'Operational access for FinOps team',
'permissions': [
'resources:read',
'resources:update',
'reports:read',
'reports:create',
'scans:execute',
'scans:read'
]
},
'finops:viewer': {
'description': 'Read-only access for stakeholders',
'permissions': [
'resources:read',
'reports:read',
'scans:read'
]
},
'finops:auditor': {
'description': 'Audit and compliance access',
'permissions': [
'resources:read',
'reports:read',
'audit:read',
'compliance:read'
]
}
}
PERMISSIONS = {
'resources:read': 'View resource information',
'resources:update': 'Update resource metadata',
'resources:delete': 'Delete or archive resources',
'reports:read': 'View reports and analytics',
'reports:create': 'Generate new reports',
'reports:delete': 'Delete reports',
'scans:read': 'View scan results',
'scans:execute': 'Execute resource scans',
'scans:configure': 'Configure scan parameters',
'settings:read': 'View system settings',
'settings:update': 'Modify system settings',
'users:read': 'View user information',
'users:manage': 'Manage user accounts'
}# Permission validation decorator
from functools import wraps
import json
def require_permission(permission: str):
"""Decorator to enforce permission requirements."""
def decorator(func):
@wraps(func)
def wrapper(event, context):
# Extract user context from API Gateway authorizer
user_context = event.get('requestContext', {}).get('authorizer', {})
user_permissions = user_context.get('permissions', '').split(',')
# Check permission
if not has_permission(user_permissions, permission):
return {
'statusCode': 403,
'body': json.dumps({
'error': 'Forbidden',
'message': f'Required permission: {permission}'
})
}
return func(event, context)
return wrapper
return decorator
def has_permission(user_permissions: list, required_permission: str) -> bool:
"""Check if user has required permission."""
# Check exact match
if required_permission in user_permissions:
return True
# Check wildcard permissions
resource, action = required_permission.split(':')
wildcard_permission = f"{resource}:*"
if wildcard_permission in user_permissions:
return True
# Check admin wildcard
if '*:*' in user_permissions:
return True
return False
# Usage example
@require_permission('resources:read')
def get_resources(event, context):
"""Get resources endpoint with permission check."""
# Implementation here
pass# Resource access control based on attributes
def check_resource_access(user_context: Dict[str, Any], resource: Dict[str, Any], action: str) -> bool:
"""Check if user can access specific resource based on attributes."""
user_account = user_context.get('account_id')
user_departments = user_context.get('departments', [])
user_roles = user_context.get('roles', [])
resource_account = resource.get('accountId')
resource_department = resource.get('tags', {}).get('Department')
resource_environment = resource.get('tags', {}).get('Environment')
# Account-level access control
if user_account != resource_account and 'finops:admin' not in user_roles:
return False
# Department-level access control
if resource_department and resource_department not in user_departments:
if 'finops:admin' not in user_roles:
return False
# Environment-level access control
if action in ['resources:delete', 'resources:cleanup']:
if resource_environment == 'production' and 'finops:operator' not in user_roles:
return False
return True
# Usage in Lambda function
@require_permission('resources:read')
def get_resource(event, context):
"""Get specific resource with attribute-based access control."""
resource_id = event['pathParameters']['resourceId']
user_context = event['requestContext']['authorizer']
# Get resource from database
resource = get_resource_from_db(resource_id)
# Check attribute-based access
if not check_resource_access(user_context, resource, 'resources:read'):
return {
'statusCode': 403,
'body': json.dumps({
'error': 'Forbidden',
'message': 'Access denied to this resource'
})
}
return {
'statusCode': 200,
'body': json.dumps(resource)
}# Cross-account access management
class MultiAccountAccessManager:
def __init__(self):
self.sts_client = boto3.client('sts')
def assume_role_for_account(self, account_id: str, role_name: str = 'FinOpsResourceCleanupRole') -> Dict[str, Any]:
"""Assume role in target account for resource access."""
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
try:
response = self.sts_client.assume_role(
RoleArn=role_arn,
RoleSessionName=f"finops-session-{int(time.time())}",
DurationSeconds=3600 # 1 hour
)
return response['Credentials']
except ClientError as e:
if e.response['Error']['Code'] == 'AccessDenied':
raise Exception(f"Cannot assume role in account {account_id}")
raise
def get_account_client(self, service: str, account_id: str) -> boto3.client:
"""Get boto3 client for specific account."""
if account_id == self.get_current_account():
return boto3.client(service)
credentials = self.assume_role_for_account(account_id)
return boto3.client(
service,
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken']
)
def get_current_account(self) -> str:
"""Get current AWS account ID."""
return self.sts_client.get_caller_identity()['Account']
# Usage
access_manager = MultiAccountAccessManager()
ec2_client = access_manager.get_account_client('ec2', '123456789012'){
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:us-east-1:123456789012:*/*/GET/resources",
"Condition": {
"StringEquals": {
"aws:PrincipalAccount": "123456789012"
}
}
},
{
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:us-east-1:123456789012:*/*/POST/scans",
"Condition": {
"StringLike": {
"aws:PrincipalArn": "arn:aws:iam::123456789012:role/FinOps*"
}
}
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": "arn:aws:execute-api:us-east-1:123456789012:*/*/DELETE/*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalTag/Department": ["FinOps", "Security"]
}
}
}
]
}# CloudFormation template for API Gateway throttling
ApiGatewayUsagePlan:
Type: AWS::ApiGateway::UsagePlan
Properties:
UsagePlanName: FinOpsAPIUsagePlan
Description: Usage plan for FinOps API
ApiStages:
- ApiId: !Ref FinOpsAPI
Stage: !Ref FinOpsAPIStage
Throttle:
BurstLimit: 100
RateLimit: 50
Quota:
Limit: 10000
Period: DAY
# Method-specific throttling
ResourcesGetMethod:
Type: AWS::ApiGateway::Method
Properties:
HttpMethod: GET
ResourceId: !Ref ResourcesResource
RestApiId: !Ref FinOpsAPI
AuthorizationType: AWS_IAM
MethodResponses:
- StatusCode: 200
- StatusCode: 403
- StatusCode: 429 # Too Many Requests
Integration:
Type: AWS_PROXY
IntegrationHttpMethod: POST
Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetResourcesFunction.Arn}/invocations"# Token refresh mechanism
class TokenManager:
def __init__(self):
self.current_token = None
self.refresh_token = None
self.token_expiry = None
def get_valid_token(self) -> str:
"""Get valid token, refreshing if necessary."""
if self.is_token_expired():
self.refresh_access_token()
return self.current_token
def is_token_expired(self) -> bool:
"""Check if current token is expired."""
if not self.token_expiry:
return True
# Refresh 5 minutes before expiry
buffer_time = 300
return time.time() > (self.token_expiry - buffer_time)
def refresh_access_token(self):
"""Refresh access token using refresh token."""
response = requests.post(
'https://auth.finops.company.com/token/refresh',
json={'refresh_token': self.refresh_token}
)
if response.status_code == 200:
token_data = response.json()
self.current_token = token_data['access_token']
self.token_expiry = time.time() + token_data['expires_in']
else:
raise Exception('Token refresh failed')# Secure token storage using AWS Secrets Manager
import boto3
import json
class SecureTokenStorage:
def __init__(self):
self.secrets_client = boto3.client('secretsmanager')
def store_token(self, user_id: str, token_data: Dict[str, Any]):
"""Store token securely in Secrets Manager."""
secret_name = f"finops/user-tokens/{user_id}"
try:
self.secrets_client.create_secret(
Name=secret_name,
SecretString=json.dumps(token_data),
Description=f"API tokens for user {user_id}"
)
except self.secrets_client.exceptions.ResourceExistsException:
self.secrets_client.update_secret(
SecretId=secret_name,
SecretString=json.dumps(token_data)
)
def get_token(self, user_id: str) -> Dict[str, Any]:
"""Retrieve token from Secrets Manager."""
secret_name = f"finops/user-tokens/{user_id}"
try:
response = self.secrets_client.get_secret_value(SecretId=secret_name)
return json.loads(response['SecretString'])
except self.secrets_client.exceptions.ResourceNotFoundException:
return None# Authentication audit logging
import json
from datetime import datetime
def log_authentication_event(event_type: str, user_id: str, success: bool, details: Dict[str, Any] = None):
"""Log authentication events for audit trail."""
audit_event = {
'timestamp': datetime.utcnow().isoformat(),
'event_type': event_type,
'user_id': user_id,
'success': success,
'source_ip': details.get('source_ip') if details else None,
'user_agent': details.get('user_agent') if details else None,
'api_endpoint': details.get('api_endpoint') if details else None,
'session_id': details.get('session_id') if details else None
}
# Send to CloudWatch Logs
cloudwatch_logs = boto3.client('logs')
cloudwatch_logs.put_log_events(
logGroupName='/aws/lambda/finops-auth-audit',
logStreamName=f"auth-events-{datetime.now().strftime('%Y-%m-%d')}",
logEvents=[
{
'timestamp': int(time.time() * 1000),
'message': json.dumps(audit_event)
}
]
)
# Usage in authentication functions
def authenticate_user(event, context):
try:
# Authentication logic
user = validate_credentials(event)
log_authentication_event(
'login_success',
user['user_id'],
True,
{
'source_ip': event.get('requestContext', {}).get('identity', {}).get('sourceIp'),
'user_agent': event.get('headers', {}).get('User-Agent')
}
)
return generate_success_response(user)
except AuthenticationError as e:
log_authentication_event(
'login_failure',
event.get('username', 'unknown'),
False,
{'error': str(e)}
)
return generate_error_response(str(e))# Security headers for API responses
def add_security_headers(response: Dict[str, Any]) -> Dict[str, Any]:
"""Add security headers to API responses."""
security_headers = {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Content-Security-Policy': "default-src 'self'",
'Referrer-Policy': 'strict-origin-when-cross-origin'
}
if 'headers' not in response:
response['headers'] = {}
response['headers'].update(security_headers)
return response
# Lambda function wrapper
def secure_lambda_handler(func):
"""Decorator to add security headers to Lambda responses."""
@wraps(func)
def wrapper(event, context):
response = func(event, context)
return add_security_headers(response)
return wrapper# Standard authentication error responses
class AuthenticationError(Exception):
"""Base authentication error."""
pass
class TokenExpiredError(AuthenticationError):
"""Token has expired."""
pass
class InvalidTokenError(AuthenticationError):
"""Token is invalid or malformed."""
pass
class InsufficientPermissionsError(AuthenticationError):
"""User lacks required permissions."""
pass
def handle_auth_error(error: Exception) -> Dict[str, Any]:
"""Handle authentication errors with appropriate responses."""
error_responses = {
TokenExpiredError: {
'statusCode': 401,
'body': json.dumps({
'error': 'token_expired',
'message': 'Token has expired, please refresh'
})
},
InvalidTokenError: {
'statusCode': 401,
'body': json.dumps({
'error': 'invalid_token',
'message': 'Token is invalid or malformed'
})
},
InsufficientPermissionsError: {
'statusCode': 403,
'body': json.dumps({
'error': 'insufficient_permissions',
'message': 'User lacks required permissions'
})
}
}
error_type = type(error)
if error_type in error_responses:
return error_responses[error_type]
# Default error response
return {
'statusCode': 401,
'body': json.dumps({
'error': 'authentication_failed',
'message': 'Authentication failed'
})
}# tests/test_authentication.py
import pytest
from unittest.mock import Mock, patch
from src.auth.jwt_validator import validate_jwt_token
class TestAuthentication:
def test_valid_jwt_token(self):
"""Test validation of valid JWT token."""
valid_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
with patch('src.auth.jwt_validator.get_public_key') as mock_key:
mock_key.return_value = "mock_public_key"
with patch('jwt.decode') as mock_decode:
mock_decode.return_value = {
'sub': 'user@company.com',
'roles': ['finops:viewer'],
'permissions': ['resources:read']
}
result = validate_jwt_token(valid_token)
assert result['sub'] == 'user@company.com'
assert 'finops:viewer' in result['roles']
def test_expired_jwt_token(self):
"""Test handling of expired JWT token."""
expired_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."
with patch('jwt.decode') as mock_decode:
mock_decode.side_effect = jwt.ExpiredSignatureError()
with pytest.raises(TokenExpiredError):
validate_jwt_token(expired_token)After implementing authentication and authorization:
- API Documentation: Review API Endpoints for complete API reference
- Error Handling: Implement Error Handling patterns
- Security: Review Security Policies
- Monitoring: Set up CloudWatch Integration for auth events