stackport 0.1.6__tar.gz → 0.1.8__tar.gz
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.
- {stackport-0.1.6/stackport.egg-info → stackport-0.1.8}/PKG-INFO +4 -4
- {stackport-0.1.6 → stackport-0.1.8}/README.md +3 -3
- {stackport-0.1.6 → stackport-0.1.8}/backend/main.py +2 -1
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/resources.py +36 -4
- stackport-0.1.8/backend/routes/secretsmanager.py +112 -0
- {stackport-0.1.6 → stackport-0.1.8}/pyproject.toml +1 -1
- {stackport-0.1.6 → stackport-0.1.8/stackport.egg-info}/PKG-INFO +4 -4
- {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/SOURCES.txt +6 -4
- stackport-0.1.8/tests/test_secretsmanager_routes.py +281 -0
- stackport-0.1.8/ui/dist/assets/index-BAMXqPoR.js +537 -0
- stackport-0.1.8/ui/dist/assets/index-CU1gzmix.css +1 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/index.html +2 -2
- stackport-0.1.6/ui/dist/assets/index-DI-V3ZCb.js +0 -527
- stackport-0.1.6/ui/dist/assets/index-DM3oKaVN.css +0 -1
- {stackport-0.1.6 → stackport-0.1.8}/LICENSE +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/MANIFEST.in +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/__init__.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/aws_client.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/cache.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/config.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/__init__.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/dynamodb.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/ec2.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/iam.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/logs.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/s3.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/sqs.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/backend/routes/stats.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/setup.cfg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_cache.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_client.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_config.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_ec2_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_iam_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_lambda_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_logs_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_registries.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/tests/test_sqs_routes.py +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/favicon.png +0 -0
- {stackport-0.1.6 → stackport-0.1.8}/ui/dist/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackport
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Universal AWS resource browser for local emulators
|
|
5
5
|
Author: Davi Reis Vieira
|
|
6
6
|
License: MIT
|
|
@@ -50,13 +50,13 @@ Dynamic: license-file
|
|
|
50
50
|
## Screenshots
|
|
51
51
|
|
|
52
52
|
**Dashboard** — Service overview with resource counts and health status
|
|
53
|
-

|
|
53
|
+

|
|
54
54
|
|
|
55
55
|
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
56
|
-

|
|
56
|
+

|
|
57
57
|
|
|
58
58
|
**S3 Browser** — File browser with folder navigation and object preview
|
|
59
|
-

|
|
59
|
+

|
|
60
60
|
|
|
61
61
|
## Features
|
|
62
62
|
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
## Screenshots
|
|
19
19
|
|
|
20
20
|
**Dashboard** — Service overview with resource counts and health status
|
|
21
|
-

|
|
21
|
+

|
|
22
22
|
|
|
23
23
|
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
24
|
-

|
|
24
|
+

|
|
25
25
|
|
|
26
26
|
**S3 Browser** — File browser with folder navigation and object preview
|
|
27
|
-

|
|
27
|
+

|
|
28
28
|
|
|
29
29
|
## Features
|
|
30
30
|
|
|
@@ -8,7 +8,7 @@ from fastapi.responses import FileResponse
|
|
|
8
8
|
from fastapi.staticfiles import StaticFiles
|
|
9
9
|
|
|
10
10
|
from backend.config import LOG_LEVEL, STACKPORT_PORT
|
|
11
|
-
from backend.routes import dynamodb, ec2, iam, lambda_svc, logs, resources, s3, sqs, stats
|
|
11
|
+
from backend.routes import dynamodb, ec2, iam, lambda_svc, logs, resources, s3, secretsmanager, sqs, stats
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class HealthcheckFilter(logging.Filter):
|
|
@@ -47,6 +47,7 @@ app.include_router(sqs.router, prefix="/api/sqs", tags=["sqs"])
|
|
|
47
47
|
app.include_router(iam.router, prefix="/api/iam", tags=["iam"])
|
|
48
48
|
app.include_router(ec2.router, prefix="/api/ec2", tags=["ec2"])
|
|
49
49
|
app.include_router(logs.router, prefix="/api/logs", tags=["logs"])
|
|
50
|
+
app.include_router(secretsmanager.router, prefix="/api/secretsmanager", tags=["secretsmanager"])
|
|
50
51
|
app.include_router(resources.router, prefix="/api")
|
|
51
52
|
|
|
52
53
|
# Serve UI static files — mount assets under /assets, SPA fallback for everything else
|
|
@@ -71,6 +71,15 @@ DESCRIBE_REGISTRY: dict[tuple[str, str], tuple[str, str, str, str | None]] = {
|
|
|
71
71
|
("elasticmapreduce", "clusters"): ("emr", "describe_cluster", "ClusterId", "Cluster"),
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
# Override the generic _ID_FIELDS for services where the default order picks
|
|
75
|
+
# the wrong field (e.g. Arn before Name, or Name before Id).
|
|
76
|
+
_PREFERRED_ID_FIELD: dict[tuple[str, str], str] = {
|
|
77
|
+
("route53", "hosted_zones"): "Id",
|
|
78
|
+
("events", "rules"): "Name",
|
|
79
|
+
("events", "event_buses"): "Name",
|
|
80
|
+
("wafv2", "web_acls"): "Name",
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
# Known ID field names for extracting a resource identifier from list results
|
|
75
84
|
_ID_FIELDS = [
|
|
76
85
|
"BucketName",
|
|
@@ -119,11 +128,13 @@ _ID_FIELDS = [
|
|
|
119
128
|
]
|
|
120
129
|
|
|
121
130
|
|
|
122
|
-
def _extract_id(item) -> str:
|
|
131
|
+
def _extract_id(item, preferred_field: str | None = None) -> str:
|
|
123
132
|
"""Extract a usable ID from a list API result item."""
|
|
124
133
|
if isinstance(item, str):
|
|
125
134
|
return item
|
|
126
135
|
if isinstance(item, dict):
|
|
136
|
+
if preferred_field and preferred_field in item:
|
|
137
|
+
return str(item[preferred_field])
|
|
127
138
|
for field in _ID_FIELDS:
|
|
128
139
|
if field in item:
|
|
129
140
|
return str(item[field])
|
|
@@ -134,12 +145,12 @@ def _extract_id(item) -> str:
|
|
|
134
145
|
return str(item)
|
|
135
146
|
|
|
136
147
|
|
|
137
|
-
def _summarize_item(item) -> dict:
|
|
148
|
+
def _summarize_item(item, preferred_field: str | None = None) -> dict:
|
|
138
149
|
"""Create a summary dict from a list API result item."""
|
|
139
150
|
if isinstance(item, str):
|
|
140
151
|
return {"id": item}
|
|
141
152
|
if isinstance(item, dict):
|
|
142
|
-
summary = {"id": _extract_id(item)}
|
|
153
|
+
summary = {"id": _extract_id(item, preferred_field)}
|
|
143
154
|
for key, value in item.items():
|
|
144
155
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
145
156
|
summary[key] = value
|
|
@@ -171,7 +182,8 @@ def list_resources(service: str):
|
|
|
171
182
|
# Handle nested structures (e.g., cloudfront DistributionList.Items)
|
|
172
183
|
if isinstance(items, dict) and "Items" in items:
|
|
173
184
|
items = items.get("Items", []) or []
|
|
174
|
-
|
|
185
|
+
preferred = _PREFERRED_ID_FIELD.get((service, resource_type))
|
|
186
|
+
resources[resource_type] = [_summarize_item(item, preferred) for item in items]
|
|
175
187
|
except Exception:
|
|
176
188
|
logger.debug("Failed to list %s/%s", service, resource_type, exc_info=True)
|
|
177
189
|
resources[resource_type] = []
|
|
@@ -188,6 +200,26 @@ def get_resource_detail(service: str, res_type: str, res_id: str):
|
|
|
188
200
|
if cached is not None:
|
|
189
201
|
return cached
|
|
190
202
|
|
|
203
|
+
# WAFv2 get_web_acl requires Name, Scope, AND Id — resolve Id from list first
|
|
204
|
+
if (service, res_type) == ("wafv2", "web_acls"):
|
|
205
|
+
try:
|
|
206
|
+
client = get_client("wafv2")
|
|
207
|
+
acls = client.list_web_acls(Scope="REGIONAL").get("WebACLs", [])
|
|
208
|
+
match = next((a for a in acls if a.get("Name") == res_id), None)
|
|
209
|
+
if not match:
|
|
210
|
+
raise HTTPException(404, f"Web ACL '{res_id}' not found")
|
|
211
|
+
resp = client.get_web_acl(Name=res_id, Scope="REGIONAL", Id=match["Id"])
|
|
212
|
+
resp.pop("ResponseMetadata", None)
|
|
213
|
+
detail = _serialize(resp.get("WebACL", resp))
|
|
214
|
+
result = {"service": service, "type": res_type, "id": res_id, "detail": detail}
|
|
215
|
+
cache.set(cache_key, result, ttl=5)
|
|
216
|
+
return result
|
|
217
|
+
except HTTPException:
|
|
218
|
+
raise
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.warning("Failed to get detail for %s/%s/%s", service, res_type, res_id, exc_info=True)
|
|
221
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
222
|
+
|
|
191
223
|
lookup = DESCRIBE_REGISTRY.get((service, res_type))
|
|
192
224
|
if not lookup:
|
|
193
225
|
raise HTTPException(
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Secrets Manager service-specific routes."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
|
|
8
|
+
from backend.aws_client import get_client
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _format_date(dt) -> str | None:
|
|
14
|
+
"""Format a datetime to ISO string, or return None."""
|
|
15
|
+
if dt is None:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return dt.isoformat()
|
|
19
|
+
except Exception:
|
|
20
|
+
return str(dt)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/secrets")
|
|
24
|
+
def list_secrets() -> dict[str, Any]:
|
|
25
|
+
"""List all secrets with metadata."""
|
|
26
|
+
try:
|
|
27
|
+
client = get_client("secretsmanager")
|
|
28
|
+
paginator = client.get_paginator("list_secrets")
|
|
29
|
+
|
|
30
|
+
secrets = []
|
|
31
|
+
for page in paginator.paginate():
|
|
32
|
+
for secret in page.get("SecretList", []):
|
|
33
|
+
secrets.append(
|
|
34
|
+
{
|
|
35
|
+
"name": secret.get("Name"),
|
|
36
|
+
"arn": secret.get("ARN"),
|
|
37
|
+
"description": secret.get("Description", ""),
|
|
38
|
+
"createdDate": _format_date(secret.get("CreatedDate")),
|
|
39
|
+
"lastChangedDate": _format_date(
|
|
40
|
+
secret.get("LastChangedDate")
|
|
41
|
+
),
|
|
42
|
+
"lastAccessedDate": _format_date(
|
|
43
|
+
secret.get("LastAccessedDate")
|
|
44
|
+
),
|
|
45
|
+
"rotationEnabled": secret.get("RotationEnabled", False),
|
|
46
|
+
"tags": {
|
|
47
|
+
tag["Key"]: tag["Value"]
|
|
48
|
+
for tag in secret.get("Tags", [])
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return {"secrets": secrets}
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/secrets/{secret_id:path}")
|
|
59
|
+
def get_secret_detail(secret_id: str) -> dict[str, Any]:
|
|
60
|
+
"""Get secret metadata and value."""
|
|
61
|
+
try:
|
|
62
|
+
client = get_client("secretsmanager")
|
|
63
|
+
|
|
64
|
+
# Get metadata
|
|
65
|
+
try:
|
|
66
|
+
meta = client.describe_secret(SecretId=secret_id)
|
|
67
|
+
except client.exceptions.ResourceNotFoundException:
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=404, detail=f"Secret '{secret_id}' not found"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Get value
|
|
73
|
+
secret_value = None
|
|
74
|
+
secret_binary = None
|
|
75
|
+
version_id = None
|
|
76
|
+
version_stages = None
|
|
77
|
+
try:
|
|
78
|
+
value_resp = client.get_secret_value(SecretId=secret_id)
|
|
79
|
+
secret_value = value_resp.get("SecretString")
|
|
80
|
+
raw_binary = value_resp.get("SecretBinary")
|
|
81
|
+
if raw_binary is not None:
|
|
82
|
+
secret_binary = base64.b64encode(raw_binary).decode("utf-8")
|
|
83
|
+
version_id = value_resp.get("VersionId")
|
|
84
|
+
version_stages = value_resp.get("VersionStages")
|
|
85
|
+
except Exception:
|
|
86
|
+
# Value may not be retrievable (e.g., pending deletion)
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"name": meta.get("Name"),
|
|
91
|
+
"arn": meta.get("ARN"),
|
|
92
|
+
"description": meta.get("Description", ""),
|
|
93
|
+
"createdDate": _format_date(meta.get("CreatedDate")),
|
|
94
|
+
"lastChangedDate": _format_date(meta.get("LastChangedDate")),
|
|
95
|
+
"lastAccessedDate": _format_date(meta.get("LastAccessedDate")),
|
|
96
|
+
"rotationEnabled": meta.get("RotationEnabled", False),
|
|
97
|
+
"rotationRules": meta.get("RotationRules"),
|
|
98
|
+
"rotationLambdaARN": meta.get("RotationLambdaARN"),
|
|
99
|
+
"deletedDate": _format_date(meta.get("DeletedDate")),
|
|
100
|
+
"tags": {
|
|
101
|
+
tag["Key"]: tag["Value"]
|
|
102
|
+
for tag in meta.get("Tags", [])
|
|
103
|
+
},
|
|
104
|
+
"versionId": version_id,
|
|
105
|
+
"versionStages": version_stages,
|
|
106
|
+
"secretValue": secret_value,
|
|
107
|
+
"secretBinary": secret_binary,
|
|
108
|
+
}
|
|
109
|
+
except HTTPException:
|
|
110
|
+
raise
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackport
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Universal AWS resource browser for local emulators
|
|
5
5
|
Author: Davi Reis Vieira
|
|
6
6
|
License: MIT
|
|
@@ -50,13 +50,13 @@ Dynamic: license-file
|
|
|
50
50
|
## Screenshots
|
|
51
51
|
|
|
52
52
|
**Dashboard** — Service overview with resource counts and health status
|
|
53
|
-

|
|
53
|
+

|
|
54
54
|
|
|
55
55
|
**DynamoDB Browser** — Generic resource table with search, pagination, and detail view
|
|
56
|
-

|
|
56
|
+

|
|
57
57
|
|
|
58
58
|
**S3 Browser** — File browser with folder navigation and object preview
|
|
59
|
-

|
|
59
|
+

|
|
60
60
|
|
|
61
61
|
## Features
|
|
62
62
|
|
|
@@ -10,8 +10,8 @@ backend/main.py
|
|
|
10
10
|
backend/../ui/dist/favicon.png
|
|
11
11
|
backend/../ui/dist/favicon.svg
|
|
12
12
|
backend/../ui/dist/index.html
|
|
13
|
-
backend/../ui/dist/assets/index-
|
|
14
|
-
backend/../ui/dist/assets/index-
|
|
13
|
+
backend/../ui/dist/assets/index-BAMXqPoR.js
|
|
14
|
+
backend/../ui/dist/assets/index-CU1gzmix.css
|
|
15
15
|
backend/../ui/dist/aws-icons/acm.svg
|
|
16
16
|
backend/../ui/dist/aws-icons/apigateway.svg
|
|
17
17
|
backend/../ui/dist/aws-icons/appsync.svg
|
|
@@ -54,6 +54,7 @@ backend/routes/lambda_svc.py
|
|
|
54
54
|
backend/routes/logs.py
|
|
55
55
|
backend/routes/resources.py
|
|
56
56
|
backend/routes/s3.py
|
|
57
|
+
backend/routes/secretsmanager.py
|
|
57
58
|
backend/routes/sqs.py
|
|
58
59
|
backend/routes/stats.py
|
|
59
60
|
stackport.egg-info/PKG-INFO
|
|
@@ -72,12 +73,13 @@ tests/test_lambda_routes.py
|
|
|
72
73
|
tests/test_logs_routes.py
|
|
73
74
|
tests/test_registries.py
|
|
74
75
|
tests/test_routes.py
|
|
76
|
+
tests/test_secretsmanager_routes.py
|
|
75
77
|
tests/test_sqs_routes.py
|
|
76
78
|
ui/dist/favicon.png
|
|
77
79
|
ui/dist/favicon.svg
|
|
78
80
|
ui/dist/index.html
|
|
79
|
-
ui/dist/assets/index-
|
|
80
|
-
ui/dist/assets/index-
|
|
81
|
+
ui/dist/assets/index-BAMXqPoR.js
|
|
82
|
+
ui/dist/assets/index-CU1gzmix.css
|
|
81
83
|
ui/dist/aws-icons/acm.svg
|
|
82
84
|
ui/dist/aws-icons/apigateway.svg
|
|
83
85
|
ui/dist/aws-icons/appsync.svg
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Integration tests for Secrets Manager API routes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
os.environ.setdefault("AWS_ENDPOINT_URL", "http://localhost:4566")
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
from backend.main import app
|
|
13
|
+
|
|
14
|
+
client = TestClient(app)
|
|
15
|
+
|
|
16
|
+
CREATED = datetime(2025, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
|
17
|
+
CHANGED = datetime(2025, 3, 20, 14, 0, 0, tzinfo=timezone.utc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestListSecrets:
|
|
21
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
22
|
+
def test_list_secrets_empty(self, mock_get_client):
|
|
23
|
+
mock_sm = MagicMock()
|
|
24
|
+
mock_get_client.return_value = mock_sm
|
|
25
|
+
paginator = MagicMock()
|
|
26
|
+
mock_sm.get_paginator.return_value = paginator
|
|
27
|
+
paginator.paginate.return_value = [{"SecretList": []}]
|
|
28
|
+
|
|
29
|
+
resp = client.get("/api/secretsmanager/secrets")
|
|
30
|
+
assert resp.status_code == 200
|
|
31
|
+
data = resp.json()
|
|
32
|
+
assert data["secrets"] == []
|
|
33
|
+
|
|
34
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
35
|
+
def test_list_secrets_with_data(self, mock_get_client):
|
|
36
|
+
mock_sm = MagicMock()
|
|
37
|
+
mock_get_client.return_value = mock_sm
|
|
38
|
+
paginator = MagicMock()
|
|
39
|
+
mock_sm.get_paginator.return_value = paginator
|
|
40
|
+
paginator.paginate.return_value = [
|
|
41
|
+
{
|
|
42
|
+
"SecretList": [
|
|
43
|
+
{
|
|
44
|
+
"Name": "prod/db-password",
|
|
45
|
+
"ARN": "arn:aws:secretsmanager:us-east-1:000:secret:prod/db-password-abc",
|
|
46
|
+
"Description": "Production database password",
|
|
47
|
+
"CreatedDate": CREATED,
|
|
48
|
+
"LastChangedDate": CHANGED,
|
|
49
|
+
"LastAccessedDate": None,
|
|
50
|
+
"RotationEnabled": False,
|
|
51
|
+
"Tags": [
|
|
52
|
+
{"Key": "env", "Value": "prod"},
|
|
53
|
+
{"Key": "team", "Value": "backend"},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
resp = client.get("/api/secretsmanager/secrets")
|
|
61
|
+
assert resp.status_code == 200
|
|
62
|
+
data = resp.json()
|
|
63
|
+
assert len(data["secrets"]) == 1
|
|
64
|
+
s = data["secrets"][0]
|
|
65
|
+
assert s["name"] == "prod/db-password"
|
|
66
|
+
assert s["arn"] == "arn:aws:secretsmanager:us-east-1:000:secret:prod/db-password-abc"
|
|
67
|
+
assert s["description"] == "Production database password"
|
|
68
|
+
assert s["createdDate"] == CREATED.isoformat()
|
|
69
|
+
assert s["lastChangedDate"] == CHANGED.isoformat()
|
|
70
|
+
assert s["lastAccessedDate"] is None
|
|
71
|
+
assert s["rotationEnabled"] is False
|
|
72
|
+
assert s["tags"] == {"env": "prod", "team": "backend"}
|
|
73
|
+
|
|
74
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
75
|
+
def test_list_secrets_no_tags(self, mock_get_client):
|
|
76
|
+
mock_sm = MagicMock()
|
|
77
|
+
mock_get_client.return_value = mock_sm
|
|
78
|
+
paginator = MagicMock()
|
|
79
|
+
mock_sm.get_paginator.return_value = paginator
|
|
80
|
+
paginator.paginate.return_value = [
|
|
81
|
+
{
|
|
82
|
+
"SecretList": [
|
|
83
|
+
{
|
|
84
|
+
"Name": "my-secret",
|
|
85
|
+
"ARN": "arn:aws:secretsmanager:us-east-1:000:secret:my-secret-xyz",
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
resp = client.get("/api/secretsmanager/secrets")
|
|
92
|
+
assert resp.status_code == 200
|
|
93
|
+
s = resp.json()["secrets"][0]
|
|
94
|
+
assert s["tags"] == {}
|
|
95
|
+
assert s["description"] == ""
|
|
96
|
+
assert s["rotationEnabled"] is False
|
|
97
|
+
|
|
98
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
99
|
+
def test_list_secrets_multiple_pages(self, mock_get_client):
|
|
100
|
+
mock_sm = MagicMock()
|
|
101
|
+
mock_get_client.return_value = mock_sm
|
|
102
|
+
paginator = MagicMock()
|
|
103
|
+
mock_sm.get_paginator.return_value = paginator
|
|
104
|
+
paginator.paginate.return_value = [
|
|
105
|
+
{"SecretList": [{"Name": "secret-1", "ARN": "arn:1"}]},
|
|
106
|
+
{"SecretList": [{"Name": "secret-2", "ARN": "arn:2"}]},
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
resp = client.get("/api/secretsmanager/secrets")
|
|
110
|
+
assert resp.status_code == 200
|
|
111
|
+
data = resp.json()
|
|
112
|
+
assert len(data["secrets"]) == 2
|
|
113
|
+
assert data["secrets"][0]["name"] == "secret-1"
|
|
114
|
+
assert data["secrets"][1]["name"] == "secret-2"
|
|
115
|
+
|
|
116
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
117
|
+
def test_list_secrets_rotation_enabled(self, mock_get_client):
|
|
118
|
+
mock_sm = MagicMock()
|
|
119
|
+
mock_get_client.return_value = mock_sm
|
|
120
|
+
paginator = MagicMock()
|
|
121
|
+
mock_sm.get_paginator.return_value = paginator
|
|
122
|
+
paginator.paginate.return_value = [
|
|
123
|
+
{
|
|
124
|
+
"SecretList": [
|
|
125
|
+
{
|
|
126
|
+
"Name": "rotated-secret",
|
|
127
|
+
"ARN": "arn:rotated",
|
|
128
|
+
"RotationEnabled": True,
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
resp = client.get("/api/secretsmanager/secrets")
|
|
135
|
+
assert resp.status_code == 200
|
|
136
|
+
assert resp.json()["secrets"][0]["rotationEnabled"] is True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestGetSecretDetail:
|
|
140
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
141
|
+
def test_get_secret_with_string_value(self, mock_get_client):
|
|
142
|
+
mock_sm = MagicMock()
|
|
143
|
+
mock_get_client.return_value = mock_sm
|
|
144
|
+
mock_sm.describe_secret.return_value = {
|
|
145
|
+
"Name": "prod/db-password",
|
|
146
|
+
"ARN": "arn:aws:secretsmanager:us-east-1:000:secret:prod/db-password-abc",
|
|
147
|
+
"Description": "Production database password",
|
|
148
|
+
"CreatedDate": CREATED,
|
|
149
|
+
"LastChangedDate": CHANGED,
|
|
150
|
+
"LastAccessedDate": None,
|
|
151
|
+
"RotationEnabled": False,
|
|
152
|
+
"RotationRules": None,
|
|
153
|
+
"RotationLambdaARN": None,
|
|
154
|
+
"DeletedDate": None,
|
|
155
|
+
"Tags": [{"Key": "env", "Value": "prod"}],
|
|
156
|
+
}
|
|
157
|
+
mock_sm.get_secret_value.return_value = {
|
|
158
|
+
"SecretString": '{"username":"admin","password":"s3cret"}',
|
|
159
|
+
"VersionId": "ver-001",
|
|
160
|
+
"VersionStages": ["AWSCURRENT"],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
resp = client.get("/api/secretsmanager/secrets/prod%2Fdb-password")
|
|
164
|
+
assert resp.status_code == 200
|
|
165
|
+
data = resp.json()
|
|
166
|
+
assert data["name"] == "prod/db-password"
|
|
167
|
+
assert data["description"] == "Production database password"
|
|
168
|
+
assert data["secretValue"] == '{"username":"admin","password":"s3cret"}'
|
|
169
|
+
assert data["secretBinary"] is None
|
|
170
|
+
assert data["versionId"] == "ver-001"
|
|
171
|
+
assert data["versionStages"] == ["AWSCURRENT"]
|
|
172
|
+
assert data["tags"] == {"env": "prod"}
|
|
173
|
+
assert data["createdDate"] == CREATED.isoformat()
|
|
174
|
+
assert data["rotationEnabled"] is False
|
|
175
|
+
|
|
176
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
177
|
+
def test_get_secret_with_binary_value(self, mock_get_client):
|
|
178
|
+
mock_sm = MagicMock()
|
|
179
|
+
mock_get_client.return_value = mock_sm
|
|
180
|
+
mock_sm.describe_secret.return_value = {
|
|
181
|
+
"Name": "binary-secret",
|
|
182
|
+
"ARN": "arn:binary",
|
|
183
|
+
"Tags": [],
|
|
184
|
+
}
|
|
185
|
+
raw_bytes = b"\x00\x01\x02\x03\xff"
|
|
186
|
+
mock_sm.get_secret_value.return_value = {
|
|
187
|
+
"SecretBinary": raw_bytes,
|
|
188
|
+
"VersionId": "ver-bin",
|
|
189
|
+
"VersionStages": ["AWSCURRENT"],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
resp = client.get("/api/secretsmanager/secrets/binary-secret")
|
|
193
|
+
assert resp.status_code == 200
|
|
194
|
+
data = resp.json()
|
|
195
|
+
assert data["secretValue"] is None
|
|
196
|
+
import base64
|
|
197
|
+
assert data["secretBinary"] == base64.b64encode(raw_bytes).decode("utf-8")
|
|
198
|
+
assert data["versionId"] == "ver-bin"
|
|
199
|
+
|
|
200
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
201
|
+
def test_get_secret_not_found(self, mock_get_client):
|
|
202
|
+
mock_sm = MagicMock()
|
|
203
|
+
mock_get_client.return_value = mock_sm
|
|
204
|
+
mock_sm.exceptions.ResourceNotFoundException = type(
|
|
205
|
+
"ResourceNotFoundException", (Exception,), {}
|
|
206
|
+
)
|
|
207
|
+
mock_sm.describe_secret.side_effect = (
|
|
208
|
+
mock_sm.exceptions.ResourceNotFoundException()
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
resp = client.get("/api/secretsmanager/secrets/nonexistent")
|
|
212
|
+
assert resp.status_code == 404
|
|
213
|
+
|
|
214
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
215
|
+
def test_get_secret_value_not_retrievable(self, mock_get_client):
|
|
216
|
+
"""When get_secret_value fails (e.g. pending deletion), metadata is still returned."""
|
|
217
|
+
mock_sm = MagicMock()
|
|
218
|
+
mock_get_client.return_value = mock_sm
|
|
219
|
+
mock_sm.describe_secret.return_value = {
|
|
220
|
+
"Name": "deleted-secret",
|
|
221
|
+
"ARN": "arn:deleted",
|
|
222
|
+
"DeletedDate": CHANGED,
|
|
223
|
+
"Tags": [],
|
|
224
|
+
}
|
|
225
|
+
mock_sm.get_secret_value.side_effect = Exception("marked for deletion")
|
|
226
|
+
|
|
227
|
+
resp = client.get("/api/secretsmanager/secrets/deleted-secret")
|
|
228
|
+
assert resp.status_code == 200
|
|
229
|
+
data = resp.json()
|
|
230
|
+
assert data["name"] == "deleted-secret"
|
|
231
|
+
assert data["secretValue"] is None
|
|
232
|
+
assert data["secretBinary"] is None
|
|
233
|
+
assert data["versionId"] is None
|
|
234
|
+
assert data["versionStages"] is None
|
|
235
|
+
assert data["deletedDate"] == CHANGED.isoformat()
|
|
236
|
+
|
|
237
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
238
|
+
def test_get_secret_with_rotation(self, mock_get_client):
|
|
239
|
+
mock_sm = MagicMock()
|
|
240
|
+
mock_get_client.return_value = mock_sm
|
|
241
|
+
mock_sm.describe_secret.return_value = {
|
|
242
|
+
"Name": "rotated-secret",
|
|
243
|
+
"ARN": "arn:rotated",
|
|
244
|
+
"RotationEnabled": True,
|
|
245
|
+
"RotationRules": {"AutomaticallyAfterDays": 30},
|
|
246
|
+
"RotationLambdaARN": "arn:aws:lambda:us-east-1:000:function:rotate-fn",
|
|
247
|
+
"Tags": [],
|
|
248
|
+
}
|
|
249
|
+
mock_sm.get_secret_value.return_value = {
|
|
250
|
+
"SecretString": "rotated-value",
|
|
251
|
+
"VersionId": "ver-rot",
|
|
252
|
+
"VersionStages": ["AWSCURRENT"],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
resp = client.get("/api/secretsmanager/secrets/rotated-secret")
|
|
256
|
+
assert resp.status_code == 200
|
|
257
|
+
data = resp.json()
|
|
258
|
+
assert data["rotationEnabled"] is True
|
|
259
|
+
assert data["rotationRules"] == {"AutomaticallyAfterDays": 30}
|
|
260
|
+
assert data["rotationLambdaARN"] == "arn:aws:lambda:us-east-1:000:function:rotate-fn"
|
|
261
|
+
assert data["secretValue"] == "rotated-value"
|
|
262
|
+
|
|
263
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
264
|
+
def test_get_secret_plain_text_value(self, mock_get_client):
|
|
265
|
+
mock_sm = MagicMock()
|
|
266
|
+
mock_get_client.return_value = mock_sm
|
|
267
|
+
mock_sm.describe_secret.return_value = {
|
|
268
|
+
"Name": "api-key",
|
|
269
|
+
"ARN": "arn:api-key",
|
|
270
|
+
"Tags": [],
|
|
271
|
+
}
|
|
272
|
+
mock_sm.get_secret_value.return_value = {
|
|
273
|
+
"SecretString": "sk-abc123def456",
|
|
274
|
+
"VersionId": "ver-plain",
|
|
275
|
+
"VersionStages": ["AWSCURRENT"],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
resp = client.get("/api/secretsmanager/secrets/api-key")
|
|
279
|
+
assert resp.status_code == 200
|
|
280
|
+
data = resp.json()
|
|
281
|
+
assert data["secretValue"] == "sk-abc123def456"
|