Security & Compliance
Module: Tuner Component: Security Architecture Version: 1.0.0-RELEASE Last Updated: October 26, 2025 Document Type: Security Architecture (Compliance Reference)
Table of Contents
- Security Overview
- AWS Account Security
- Authentication & Authorization
- Data Security
- Network Security
- Compliance & Certifications
- Security Best Practices
- Incident Response
Security Overview
Security Principles
AWS Tuner is designed with zero-trust architecture and follows defense-in-depth principles:
Key Security Features
| Feature | Implementation | Benefit |
|---|---|---|
| Read-Only Access | IAM policies with only Describe* and Get* actions | Cannot modify customer AWS resources |
| External ID | Unique per-customer identifier in AssumeRole | Prevents confused deputy attack |
| Encryption at Rest | AES-256 for all databases | Data protected if physical media compromised |
| Encryption in Transit | TLS 1.3 for all connections | Data protected during transmission |
| Secrets Management | AWS Secrets Manager | No hardcoded credentials |
| Audit Logging | All API calls logged | Complete audit trail |
| RBAC | Role-based access control | Least privilege access |
AWS Account Security
Cross-Account Access Model
AWS Tuner uses IAM AssumeRole for secure, temporary access to customer AWS accounts:
IAM Policy (Read-Only)
Recommended IAM Policy for CloudKeeperTunerRole:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TunerEC2ReadOnly",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeVolumes",
"ec2:DescribeSnapshots",
"ec2:DescribeImages",
"ec2:DescribeNatGateways",
"ec2:DescribeVpcEndpoints",
"ec2:DescribeAddresses",
"ec2:DescribeRegions",
"ec2:DescribeAvailabilityZones"
],
"Resource": "*"
},
{
"Sid": "TunerRDSReadOnly",
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances",
"rds:DescribeDBClusters",
"rds:DescribeDBSnapshots",
"rds:DescribeDBEngineVersions"
],
"Resource": "*"
},
{
"Sid": "TunerCloudWatchReadOnly",
"Effect": "Allow",
"Action": [
"cloudwatch:GetMetricStatistics",
"cloudwatch:ListMetrics"
],
"Resource": "*"
},
{
"Sid": "TunerCostExplorerReadOnly",
"Effect": "Allow",
"Action": [
"ce:GetCostAndUsage",
"ce:GetCostForecast"
],
"Resource": "*"
},
{
"Sid": "TunerPricingReadOnly",
"Effect": "Allow",
"Action": [
"pricing:GetProducts"
],
"Resource": "*"
},
{
"Sid": "TunerS3ReadOnly",
"Effect": "Allow",
"Action": [
"s3:ListAllMyBuckets",
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:GetBucketVersioning",
"s3:GetBucketLifecycleConfiguration"
],
"Resource": "*"
},
{
"Sid": "TunerDynamoDBReadOnly",
"Effect": "Allow",
"Action": [
"dynamodb:DescribeTable",
"dynamodb:ListTables",
"dynamodb:DescribeTimeToLive"
],
"Resource": "*"
},
{
"Sid": "TunerRedshiftReadOnly",
"Effect": "Allow",
"Action": [
"redshift:DescribeClusters",
"redshift:DescribeClusterSnapshots"
],
"Resource": "*"
},
{
"Sid": "TunerElastiCacheReadOnly",
"Effect": "Allow",
"Action": [
"elasticache:DescribeCacheClusters",
"elasticache:DescribeReplicationGroups"
],
"Resource": "*"
},
{
"Sid": "TunerAutoScalingReadOnly",
"Effect": "Allow",
"Action": [
"autoscaling:DescribeAutoScalingGroups",
"autoscaling:DescribeLaunchConfigurations",
"autoscaling:DescribeScalingActivities"
],
"Resource": "*"
},
{
"Sid": "TunerECSReadOnly",
"Effect": "Allow",
"Action": [
"ecs:DescribeClusters",
"ecs:DescribeServices",
"ecs:DescribeTasks"
],
"Resource": "*"
},
{
"Sid": "TunerLambdaReadOnly",
"Effect": "Allow",
"Action": [
"lambda:ListFunctions",
"lambda:GetFunction"
],
"Resource": "*"
}
]
}
External ID Best Practices
What is External ID?
External ID is a security measure to prevent the confused deputy problem:
Bad Scenario (without External ID):
1. Attacker creates account in CloudKeeper using victim's AWS account ID
2. Attacker tricks victim into creating IAM role trusting CloudKeeper
3. Attacker uses CloudKeeper to access victim's AWS account
4. ❌ Victim's data exposed to attacker
Good Scenario (with External ID):
1. Attacker creates account in CloudKeeper using victim's AWS account ID
2. CloudKeeper generates unique External ID: "uuid-12345-abcde"
3. Victim creates IAM role requiring External ID "uuid-12345-abcde"
4. Attacker cannot use CloudKeeper to access victim's account (doesn't know External ID)
5. ✅ Victim's data protected
Generating Secure External IDs:
public String generateExternalId(String accountId, String organizationId) {
// Cryptographically secure random UUID
String uuid = UUID.randomUUID().toString();
// HMAC to bind to account
String hmac = HmacUtils.hmacSha256Hex(
secretKey,
accountId + "|" + organizationId + "|" + uuid
);
return uuid + "-" + hmac.substring(0, 16);
}
Credential Rotation
Temporary Credentials (1-hour expiry):
@Component
public class AwsCredentialProvider {
private final Map<String, SessionCredentials> credentialCache = new ConcurrentHashMap<>();
public AWSCredentials getCredentials(String accountId, String roleArn, String externalId) {
String cacheKey = accountId + ":" + roleArn;
// Check if cached credentials are still valid (with 5-minute buffer)
SessionCredentials cached = credentialCache.get(cacheKey);
if (cached != null && cached.getExpiration().isAfter(Instant.now().plusSeconds(300))) {
return cached;
}
// Assume role to get fresh credentials
AssumeRoleRequest request = AssumeRoleRequest.builder()
.roleArn(roleArn)
.roleSessionName("tuner-session-" + accountId)
.externalId(externalId)
.durationSeconds(3600) // 1 hour
.build();
AssumeRoleResponse response = stsClient.assumeRole(request);
Credentials credentials = response.credentials();
SessionCredentials sessionCreds = new SessionCredentials(
credentials.accessKeyId(),
credentials.secretAccessKey(),
credentials.sessionToken(),
credentials.expiration()
);
credentialCache.put(cacheKey, sessionCreds);
return sessionCreds;
}
}
Scheduler Write Permissions (Optional)
For customers using Tuner Scheduler, additional write permissions required:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TunerSchedulerEC2Actions",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/Scheduler": "Enabled"
}
}
},
{
"Sid": "TunerSchedulerRDSActions",
"Effect": "Allow",
"Action": [
"rds:StartDBInstance",
"rds:StopDBInstance"
],
"Resource": "arn:aws:rds:*:*:db:*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/Scheduler": "Enabled"
}
}
}
]
}
Safety Mechanisms:
- ✅ Only resources tagged
Scheduler=Enabled - ✅ Read-only policy remains for recommendation generation
- ✅ Separate role or additional policy statement (customer choice)
- ✅ Audit logging of all start/stop actions
Authentication & Authorization
JWT-Based Authentication
Token Structure:
{
"header": {
"alg": "RS256",
"typ": "JWT"
},
"payload": {
"sub": "user@company.com",
"userId": "usr_abc123",
"organizationId": "org_xyz789",
"roles": ["TUNER_USER", "TUNER_ADMIN"],
"iat": 1698336000,
"exp": 1698422400,
"iss": "cloudkeeper-authx",
"aud": "cloudkeeper-tuner"
},
"signature": "..." // RSA signature
}
Token Lifecycle:
Token Validation (Spring Security):
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = extractToken(request);
if (token != null) {
try {
// 1. Verify signature and decode
DecodedJWT jwt = jwtVerifier.verify(token);
// 2. Extract claims
String userId = jwt.getSubject();
List<String> roles = jwt.getClaim("roles").asList(String.class);
String organizationId = jwt.getClaim("organizationId").asString();
// 3. Create authentication object
UserPrincipal principal = new UserPrincipal(userId, organizationId, roles);
Authentication auth = new UsernamePasswordAuthenticationToken(
principal,
null,
principal.getAuthorities()
);
// 4. Set in security context
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JWTVerificationException e) {
log.warn("Invalid JWT token: {}", e.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
filterChain.doFilter(request, response);
}
}
Role-Based Access Control (RBAC)
Roles:
| Role | Permissions | Use Case |
|---|---|---|
TUNER_VIEWER | View recommendations (read-only) | Finance team reviewing savings |
TUNER_USER | View + implement recommendations | Engineering implementing optimizations |
TUNER_ADMIN | All permissions + account management | Cloud team managing Tuner configuration |
ORGANIZATION_ADMIN | All permissions across all accounts | CTO overseeing cost optimization |
Permission Matrix:
| Action | Viewer | User | Admin | Org Admin |
|---|---|---|---|---|
| View recommendations | ✅ | ✅ | ✅ | ✅ |
| Snooze recommendations | ❌ | ✅ | ✅ | ✅ |
| Implement recommendations | ❌ | ✅ | ✅ | ✅ |
| Configure scheduler | ❌ | ❌ | ✅ | ✅ |
| Link AWS accounts | ❌ | ❌ | ✅ | ✅ |
| Manage users | ❌ | ❌ | ✅ | ✅ |
| Delete accounts | ❌ | ❌ | ❌ | ✅ |
Authorization Enforcement:
@RestController
@RequestMapping("/api/recommendations")
public class RecommendationController {
@GetMapping
@PreAuthorize("hasAnyRole('TUNER_VIEWER', 'TUNER_USER', 'TUNER_ADMIN')")
public List<RecommendationDto> getRecommendations(
@RequestParam String accountId
) {
// Check if user has access to this account
if (!accountAccessService.hasAccess(getCurrentUser(), accountId)) {
throw new AccessDeniedException("User does not have access to account: " + accountId);
}
return recommendationService.getRecommendations(accountId);
}
@PostMapping("/{id}/implement")
@PreAuthorize("hasAnyRole('TUNER_USER', 'TUNER_ADMIN')")
public void implementRecommendation(
@PathVariable String id
) {
Recommendation recommendation = recommendationService.getById(id);
// Verify user has access to the account
if (!accountAccessService.hasAccess(getCurrentUser(), recommendation.getAccountId())) {
throw new AccessDeniedException("Cannot implement recommendation in inaccessible account");
}
recommendationService.implement(id);
}
@PostMapping("/accounts/{accountId}/link")
@PreAuthorize("hasRole('TUNER_ADMIN')")
public void linkAccount(
@PathVariable String accountId,
@RequestBody LinkAccountRequest request
) {
accountService.linkAccount(accountId, request);
}
}
Data Security
Encryption at Rest
Database Encryption:
| Database | Encryption Method | Key Management |
|---|---|---|
| MongoDB | AES-256 (MongoDB Enterprise) | AWS KMS |
| MySQL | AES-256 (InnoDB encryption) | AWS KMS |
| Redis | AES-256 (Redis 6+) | AWS KMS |
| Snowflake | AES-256 (automatic) | Snowflake-managed |
Configuration Example (MySQL):
-- Enable encryption for MySQL
CREATE TABLE recommendations (
id VARCHAR(36) PRIMARY KEY,
account_id VARCHAR(12) NOT NULL,
resource_id VARCHAR(255) NOT NULL,
-- ...
) ENCRYPTION='Y';
Encryption in Transit
TLS 1.3 for all network communication:
# application.yml
server:
ssl:
enabled: true
protocol: TLS
enabled-protocols: TLSv1.3
ciphers:
- TLS_AES_256_GCM_SHA384
- TLS_AES_128_GCM_SHA256
key-store: classpath:keystore.p12
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
Database Connections:
# MongoDB
spring.data.mongodb.uri=mongodb+srv://user:pass@cluster.mongodb.net/tuner?ssl=true&tlsAllowInvalidCertificates=false
# MySQL
spring.datasource.url=jdbc:mysql://mysql-host:3306/tuner?useSSL=true&requireSSL=true&verifyServerCertificate=true
# Redis
spring.redis.ssl=true
Secrets Management
AWS Secrets Manager for all sensitive data:
@Configuration
public class SecretsConfig {
@Bean
public SecretsManagerClient secretsManagerClient() {
return SecretsManagerClient.builder()
.region(Region.US_EAST_1)
.build();
}
@Bean
public DatabaseCredentials getDatabaseCredentials() {
GetSecretValueRequest request = GetSecretValueRequest.builder()
.secretId("tuner/mysql/credentials")
.build();
GetSecretValueResponse response = secretsManagerClient().getSecretValue(request);
String secretString = response.secretString();
return new ObjectMapper().readValue(secretString, DatabaseCredentials.class);
}
}
No Hardcoded Secrets:
// ❌ BAD - Never do this
String dbPassword = "MyP@ssw0rd123!";
// ✅ GOOD - Load from environment
String dbPassword = System.getenv("DB_PASSWORD");
// ✅ BETTER - Load from Secrets Manager
String dbPassword = secretsManager.getSecret("tuner/db/password");
Data Retention & Deletion
Recommendation Data:
- Active recommendations: Retained indefinitely
- Implemented recommendations: Retained 12 months for ROI tracking
- Dismissed recommendations: Retained 3 months
- Snoozed recommendations: Retained until expiry + 30 days
User Data (GDPR Compliance):
- Right to access: Export all user data via API
- Right to erasure: Delete user and anonymize audit logs
- Right to portability: JSON export of all recommendations
Data Deletion Procedure:
@Service
public class DataRetentionService {
@Scheduled(cron = "0 0 2 * * SUN") // Weekly Sunday 2 AM
public void cleanupOldData() {
Instant cutoff12Months = Instant.now().minus(365, ChronoUnit.DAYS);
Instant cutoff3Months = Instant.now().minus(90, ChronoUnit.DAYS);
// Delete implemented recommendations older than 12 months
recommendationRepository.deleteByStatusAndCreatedAtBefore(
"IMPLEMENTED",
cutoff12Months
);
// Delete dismissed recommendations older than 3 months
recommendationRepository.deleteByStatusAndCreatedAtBefore(
"DISMISSED",
cutoff3Months
);
log.info("Data retention cleanup completed");
}
}
Network Security
Web Application Firewall (WAF)
CloudFlare WAF Rules:
WAF Rules:
- SQL Injection Protection: ENABLED
- XSS Protection: ENABLED
- Rate Limiting:
- API endpoints: 100 requests/minute per IP
- Login endpoint: 5 attempts/minute per IP
- Bot Protection: ENABLED (challenge suspected bots)
- Geo-Blocking: DISABLED (global access required)
DDoS Protection
AWS Shield Standard (automatic):
- Protection against common DDoS attacks
- Always-on detection and mitigation
- No additional cost
Rate Limiting (application level):
@Configuration
public class RateLimitConfig {
@Bean
public RateLimiter apiRateLimiter() {
return RateLimiter.create(100); // 100 requests/second
}
}
@RestController
@RequestMapping("/api")
public class ApiController {
@Autowired
private RateLimiter rateLimiter;
@GetMapping("/recommendations")
public ResponseEntity<?> getRecommendations() {
if (!rateLimiter.tryAcquire()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body("Rate limit exceeded");
}
// Process request
}
}
API Security
CORS Configuration:
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList(
"https://app.cloudkeeper.com",
"https://demo-lens.cloudkeeper.com"
));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
Content Security Policy (CSP):
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.cloudkeeper.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.cloudkeeper.com;
frame-ancestors 'none';
Compliance & Certifications
SOC 2 Type II (In Progress)
Control Objectives:
| Category | Controls | Status |
|---|---|---|
| Security | Access control, encryption, monitoring | ✅ Implemented |
| Availability | High availability, disaster recovery | ✅ Implemented |
| Processing Integrity | Data validation, error handling | ✅ Implemented |
| Confidentiality | Data classification, encryption | ✅ Implemented |
| Privacy | GDPR compliance, data retention | ✅ Implemented |
GDPR Compliance
Data Subject Rights:
-
Right to Access:
@GetMapping("/api/users/{userId}/data-export")
public ResponseEntity<UserDataExport> exportUserData(@PathVariable String userId) {
return userDataService.exportAllData(userId);
} -
Right to Erasure:
@DeleteMapping("/api/users/{userId}")
public void deleteUser(@PathVariable String userId) {
userService.deleteUser(userId); // Anonymizes audit logs
} -
Right to Portability:
- JSON export of all user recommendations
- CSV export of cost savings history
Audit Logging
All API Calls Logged:
@Component
public class AuditLogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
AuditLog log = AuditLog.builder()
.userId(getCurrentUserId())
.action(request.getMethod() + " " + request.getRequestURI())
.ipAddress(request.getRemoteAddr())
.userAgent(request.getHeader("User-Agent"))
.timestamp(Instant.now())
.build();
auditLogRepository.save(log);
return true;
}
}
Audit Log Retention: 7 years (compliance requirement)
Security Best Practices
For Customers
1. Principle of Least Privilege:
✅ DO: Grant only required IAM permissions
❌ DON'T: Use AdministratorAccess policy
2. External ID Security:
✅ DO: Keep External ID secret (treat like password)
❌ DON'T: Share External ID publicly or in code repositories
3. Regular IAM Audits:
✅ DO: Review IAM role usage quarterly
✅ DO: Rotate External ID annually
❌ DON'T: Leave unused IAM roles active
4. Multi-Factor Authentication:
✅ DO: Enable MFA for all admin users
❌ DON'T: Use shared admin accounts
For Developers
1. Secure Coding:
// ✅ GOOD - Parameterized queries
String query = "SELECT * FROM recommendations WHERE account_id = ?";
jdbcTemplate.query(query, accountId);
// ❌ BAD - SQL injection vulnerability
String query = "SELECT * FROM recommendations WHERE account_id = '" + accountId + "'";
2. Input Validation:
@PostMapping("/api/accounts/{accountId}")
public void linkAccount(
@PathVariable @Pattern(regexp = "\\d{12}") String accountId, // Validate AWS account ID format
@RequestBody @Valid LinkAccountRequest request
) {
accountService.linkAccount(accountId, request);
}
3. Error Handling (Don't leak sensitive info):
// ✅ GOOD - Generic error message
throw new AccessDeniedException("Access denied");
// ❌ BAD - Leaks implementation details
throw new Exception("Database connection failed: mysql-prod-001.internal.cloudkeeper.com:3306");
Incident Response
Security Incident Procedure
1. Detection & Triage (0-15 minutes):
- Automated alerts trigger PagerDuty
- On-call engineer assesses severity
- Escalate to security team if confirmed incident
2. Containment (15-60 minutes):
- Isolate affected systems
- Revoke compromised credentials
- Block malicious IP addresses
3. Investigation (1-24 hours):
- Analyze audit logs
- Identify root cause
- Determine scope of impact
4. Remediation (1-7 days):
- Patch vulnerabilities
- Restore from backups if needed
- Update security controls
5. Post-Incident (7-30 days):
- Document lessons learned
- Update runbooks
- Improve detection mechanisms
Breach Notification
Notification Timeline (GDPR requirement):
- Discovery → 24 hours: Internal escalation
- Discovery → 72 hours: Customer notification (if PII affected)
- Discovery → 7 days: Public disclosure (if material breach)
Next Steps
- API Reference - REST API documentation
- Data Architecture - Database schemas