stackport 0.3.0__tar.gz → 0.3.1__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.3.0/stackport.egg-info → stackport-0.3.1}/PKG-INFO +1 -1
- stackport-0.3.1/backend/routes/secretsmanager.py +256 -0
- stackport-0.3.1/backend/schemas/secretsmanager.py +33 -0
- {stackport-0.3.0 → stackport-0.3.1}/pyproject.toml +1 -1
- {stackport-0.3.0 → stackport-0.3.1/stackport.egg-info}/PKG-INFO +1 -1
- {stackport-0.3.0 → stackport-0.3.1}/stackport.egg-info/SOURCES.txt +5 -4
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_secretsmanager_routes.py +227 -0
- stackport-0.3.1/ui/dist/assets/index-CAZXHF4B.js +639 -0
- stackport-0.3.1/ui/dist/assets/index-DLd-xBZq.css +1 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/index.html +2 -2
- stackport-0.3.0/backend/routes/secretsmanager.py +0 -113
- stackport-0.3.0/ui/dist/assets/index-Cda_0qu5.css +0 -1
- stackport-0.3.0/ui/dist/assets/index-tW-8e4eS.js +0 -629
- {stackport-0.3.0 → stackport-0.3.1}/LICENSE +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/MANIFEST.in +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/README.md +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/__init__.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/aws_client.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/cache.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/cli.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/config.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/endpoint_store.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/main.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/__init__.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/common.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/dynamodb.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/ec2.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/endpoints.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/iam.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/lambda_svc.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/logs.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/resources.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/s3.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/sqs.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/stats.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/routes/tags.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/schemas/__init__.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/schemas/dynamodb.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/schemas/endpoints.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/schemas/s3.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/schemas/sqs.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/schemas/tags.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/backend/websocket.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/setup.cfg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/stackport.egg-info/dependency_links.txt +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/stackport.egg-info/entry_points.txt +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/stackport.egg-info/requires.txt +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/stackport.egg-info/top_level.txt +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_cache.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_cli.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_client.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_config.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_dynamodb_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_ec2_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_endpoint_store.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_endpoints.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_iam_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_lambda_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_logs_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_multi_endpoint.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_readonly_middleware.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_registries.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_s3_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_s3_upload_limit_env.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_sqs_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_tags_routes.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/tests/test_websocket.py +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/acm.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/apigateway.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/appsync.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/athena.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/cloudformation.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/cloudfront.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/cognito-idp.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/dynamodb.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/ec2.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/ecr.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/ecs.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/elasticache.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/elasticfilesystem.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/elasticloadbalancing.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/elasticmapreduce.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/events.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/firehose.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/glue.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/iam.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/kinesis.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/kms.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/lambda.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/logs.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/monitoring.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/rds.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/route53.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/s3.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/secretsmanager.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/ses.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/sns.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/sqs.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/ssm.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/stepfunctions.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/aws-icons/wafv2.svg +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/favicon.png +0 -0
- {stackport-0.3.0 → stackport-0.3.1}/ui/dist/favicon.svg +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Secrets Manager service-specific routes."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
7
|
+
|
|
8
|
+
from backend.aws_client import get_client
|
|
9
|
+
from backend.routes.common import EndpointInfo, get_endpoint_info
|
|
10
|
+
from backend.schemas.secretsmanager import (
|
|
11
|
+
CreateSecretBody,
|
|
12
|
+
UpdateSecretMetadataBody,
|
|
13
|
+
UpdateSecretValueBody,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _format_date(dt) -> str | None:
|
|
20
|
+
"""Format a datetime to ISO string, or return None."""
|
|
21
|
+
if dt is None:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return dt.isoformat()
|
|
25
|
+
except Exception:
|
|
26
|
+
return str(dt)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/secrets")
|
|
30
|
+
def list_secrets(ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
|
|
31
|
+
"""List all secrets with metadata."""
|
|
32
|
+
try:
|
|
33
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
34
|
+
paginator = client.get_paginator("list_secrets")
|
|
35
|
+
|
|
36
|
+
secrets = []
|
|
37
|
+
for page in paginator.paginate():
|
|
38
|
+
for secret in page.get("SecretList", []):
|
|
39
|
+
secrets.append(
|
|
40
|
+
{
|
|
41
|
+
"name": secret.get("Name"),
|
|
42
|
+
"arn": secret.get("ARN"),
|
|
43
|
+
"description": secret.get("Description", ""),
|
|
44
|
+
"createdDate": _format_date(secret.get("CreatedDate")),
|
|
45
|
+
"lastChangedDate": _format_date(
|
|
46
|
+
secret.get("LastChangedDate")
|
|
47
|
+
),
|
|
48
|
+
"lastAccessedDate": _format_date(
|
|
49
|
+
secret.get("LastAccessedDate")
|
|
50
|
+
),
|
|
51
|
+
"rotationEnabled": secret.get("RotationEnabled", False),
|
|
52
|
+
"tags": {
|
|
53
|
+
tag["Key"]: tag["Value"]
|
|
54
|
+
for tag in secret.get("Tags", [])
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return {"secrets": secrets}
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("/secrets/{secret_id:path}")
|
|
65
|
+
def get_secret_detail(secret_id: str, ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
|
|
66
|
+
"""Get secret metadata and value."""
|
|
67
|
+
try:
|
|
68
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
69
|
+
|
|
70
|
+
# Get metadata
|
|
71
|
+
try:
|
|
72
|
+
meta = client.describe_secret(SecretId=secret_id)
|
|
73
|
+
except client.exceptions.ResourceNotFoundException:
|
|
74
|
+
raise HTTPException(
|
|
75
|
+
status_code=404, detail=f"Secret '{secret_id}' not found"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Get value
|
|
79
|
+
secret_value = None
|
|
80
|
+
secret_binary = None
|
|
81
|
+
version_id = None
|
|
82
|
+
version_stages = None
|
|
83
|
+
try:
|
|
84
|
+
value_resp = client.get_secret_value(SecretId=secret_id)
|
|
85
|
+
secret_value = value_resp.get("SecretString")
|
|
86
|
+
raw_binary = value_resp.get("SecretBinary")
|
|
87
|
+
if raw_binary is not None:
|
|
88
|
+
secret_binary = base64.b64encode(raw_binary).decode("utf-8")
|
|
89
|
+
version_id = value_resp.get("VersionId")
|
|
90
|
+
version_stages = value_resp.get("VersionStages")
|
|
91
|
+
except Exception:
|
|
92
|
+
# Value may not be retrievable (e.g., pending deletion)
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"name": meta.get("Name"),
|
|
97
|
+
"arn": meta.get("ARN"),
|
|
98
|
+
"description": meta.get("Description", ""),
|
|
99
|
+
"createdDate": _format_date(meta.get("CreatedDate")),
|
|
100
|
+
"lastChangedDate": _format_date(meta.get("LastChangedDate")),
|
|
101
|
+
"lastAccessedDate": _format_date(meta.get("LastAccessedDate")),
|
|
102
|
+
"rotationEnabled": meta.get("RotationEnabled", False),
|
|
103
|
+
"rotationRules": meta.get("RotationRules"),
|
|
104
|
+
"rotationLambdaARN": meta.get("RotationLambdaARN"),
|
|
105
|
+
"deletedDate": _format_date(meta.get("DeletedDate")),
|
|
106
|
+
"tags": {
|
|
107
|
+
tag["Key"]: tag["Value"]
|
|
108
|
+
for tag in meta.get("Tags", [])
|
|
109
|
+
},
|
|
110
|
+
"versionId": version_id,
|
|
111
|
+
"versionStages": version_stages,
|
|
112
|
+
"secretValue": secret_value,
|
|
113
|
+
"secretBinary": secret_binary,
|
|
114
|
+
}
|
|
115
|
+
except HTTPException:
|
|
116
|
+
raise
|
|
117
|
+
except Exception as e:
|
|
118
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/secrets", status_code=201)
|
|
122
|
+
def create_secret(body: CreateSecretBody, ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
|
|
123
|
+
"""Create a new secret."""
|
|
124
|
+
if not body.secret_string and not body.secret_binary:
|
|
125
|
+
raise HTTPException(status_code=400, detail="Must provide either secret_string or secret_binary")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
129
|
+
|
|
130
|
+
kwargs: dict[str, Any] = {"Name": body.name}
|
|
131
|
+
if body.description:
|
|
132
|
+
kwargs["Description"] = body.description
|
|
133
|
+
if body.secret_string:
|
|
134
|
+
kwargs["SecretString"] = body.secret_string
|
|
135
|
+
if body.secret_binary:
|
|
136
|
+
kwargs["SecretBinary"] = base64.b64decode(body.secret_binary)
|
|
137
|
+
if body.tags:
|
|
138
|
+
kwargs["Tags"] = [{"Key": k, "Value": v} for k, v in body.tags.items()]
|
|
139
|
+
|
|
140
|
+
resp = client.create_secret(**kwargs)
|
|
141
|
+
return {
|
|
142
|
+
"name": resp["Name"],
|
|
143
|
+
"arn": resp["ARN"],
|
|
144
|
+
"versionId": resp.get("VersionId"),
|
|
145
|
+
}
|
|
146
|
+
except client.exceptions.ResourceExistsException:
|
|
147
|
+
raise HTTPException(status_code=409, detail=f"Secret '{body.name}' already exists")
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.put("/secrets/{secret_id:path}/value")
|
|
153
|
+
def update_secret_value(secret_id: str, body: UpdateSecretValueBody, ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
|
|
154
|
+
"""Update a secret's value."""
|
|
155
|
+
if not body.secret_string and not body.secret_binary:
|
|
156
|
+
raise HTTPException(status_code=400, detail="Must provide either secret_string or secret_binary")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
160
|
+
|
|
161
|
+
kwargs: dict[str, Any] = {"SecretId": secret_id}
|
|
162
|
+
if body.secret_string:
|
|
163
|
+
kwargs["SecretString"] = body.secret_string
|
|
164
|
+
if body.secret_binary:
|
|
165
|
+
kwargs["SecretBinary"] = base64.b64decode(body.secret_binary)
|
|
166
|
+
|
|
167
|
+
resp = client.put_secret_value(**kwargs)
|
|
168
|
+
return {
|
|
169
|
+
"arn": resp["ARN"],
|
|
170
|
+
"name": resp["Name"],
|
|
171
|
+
"versionId": resp["VersionId"],
|
|
172
|
+
"versionStages": resp.get("VersionStages"),
|
|
173
|
+
}
|
|
174
|
+
except client.exceptions.ResourceNotFoundException:
|
|
175
|
+
raise HTTPException(status_code=404, detail=f"Secret '{secret_id}' not found")
|
|
176
|
+
except Exception as e:
|
|
177
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.put("/secrets/{secret_id:path}/metadata")
|
|
181
|
+
def update_secret_metadata(secret_id: str, body: UpdateSecretMetadataBody, ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
|
|
182
|
+
"""Update a secret's description and/or tags."""
|
|
183
|
+
try:
|
|
184
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
185
|
+
|
|
186
|
+
# Update description
|
|
187
|
+
if body.description is not None:
|
|
188
|
+
client.update_secret(SecretId=secret_id, Description=body.description)
|
|
189
|
+
|
|
190
|
+
# Update tags (replace all)
|
|
191
|
+
if body.tags is not None:
|
|
192
|
+
arn = client.describe_secret(SecretId=secret_id)["ARN"]
|
|
193
|
+
# Remove all existing tags first
|
|
194
|
+
existing_tags_resp = client.describe_secret(SecretId=secret_id)
|
|
195
|
+
existing_tag_keys = [tag["Key"] for tag in existing_tags_resp.get("Tags", [])]
|
|
196
|
+
if existing_tag_keys:
|
|
197
|
+
client.untag_resource(SecretId=arn, TagKeys=existing_tag_keys)
|
|
198
|
+
# Add new tags
|
|
199
|
+
if body.tags:
|
|
200
|
+
client.tag_resource(
|
|
201
|
+
SecretId=arn,
|
|
202
|
+
Tags=[{"Key": k, "Value": v} for k, v in body.tags.items()],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return {"success": True, "message": "Metadata updated"}
|
|
206
|
+
except client.exceptions.ResourceNotFoundException:
|
|
207
|
+
raise HTTPException(status_code=404, detail=f"Secret '{secret_id}' not found")
|
|
208
|
+
except Exception as e:
|
|
209
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.delete("/secrets/{secret_id:path}")
|
|
213
|
+
def delete_secret(
|
|
214
|
+
secret_id: str,
|
|
215
|
+
force: bool = Query(False, description="Skip recovery window (immediate deletion)"),
|
|
216
|
+
ep: EndpointInfo = Depends(get_endpoint_info),
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
"""Delete a secret."""
|
|
219
|
+
try:
|
|
220
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
221
|
+
|
|
222
|
+
kwargs: dict[str, Any] = {"SecretId": secret_id}
|
|
223
|
+
if force:
|
|
224
|
+
kwargs["ForceDeleteWithoutRecovery"] = True
|
|
225
|
+
else:
|
|
226
|
+
kwargs["RecoveryWindowInDays"] = 7
|
|
227
|
+
|
|
228
|
+
resp = client.delete_secret(**kwargs)
|
|
229
|
+
return {
|
|
230
|
+
"arn": resp["ARN"],
|
|
231
|
+
"name": resp["Name"],
|
|
232
|
+
"deletionDate": _format_date(resp.get("DeletionDate")),
|
|
233
|
+
}
|
|
234
|
+
except client.exceptions.ResourceNotFoundException:
|
|
235
|
+
raise HTTPException(status_code=404, detail=f"Secret '{secret_id}' not found")
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@router.post("/secrets/{secret_id:path}/restore")
|
|
241
|
+
def restore_secret(secret_id: str, ep: EndpointInfo = Depends(get_endpoint_info)) -> dict[str, Any]:
|
|
242
|
+
"""Restore a deleted secret."""
|
|
243
|
+
try:
|
|
244
|
+
client = get_client("secretsmanager", **ep.client_kwargs())
|
|
245
|
+
resp = client.restore_secret(SecretId=secret_id)
|
|
246
|
+
return {
|
|
247
|
+
"arn": resp["ARN"],
|
|
248
|
+
"name": resp["Name"],
|
|
249
|
+
}
|
|
250
|
+
except Exception as e:
|
|
251
|
+
error_code = getattr(e, "response", {}).get("Error", {}).get("Code", "")
|
|
252
|
+
if error_code == "ResourceNotFoundException":
|
|
253
|
+
raise HTTPException(status_code=404, detail=f"Secret '{secret_id}' not found")
|
|
254
|
+
elif error_code == "InvalidRequestException":
|
|
255
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
256
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Pydantic schemas for Secrets Manager API requests."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CreateSecretBody(BaseModel):
|
|
7
|
+
"""Request body for creating a new secret."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
10
|
+
|
|
11
|
+
name: str = Field(..., description="Secret name")
|
|
12
|
+
description: str | None = Field(None, description="Secret description")
|
|
13
|
+
secret_string: str | None = Field(None, alias="secretString", description="Secret value as string")
|
|
14
|
+
secret_binary: str | None = Field(None, alias="secretBinary", description="Secret value as base64-encoded binary")
|
|
15
|
+
tags: dict[str, str] = Field(default_factory=dict, description="Resource tags")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UpdateSecretValueBody(BaseModel):
|
|
19
|
+
"""Request body for updating a secret's value."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
22
|
+
|
|
23
|
+
secret_string: str | None = Field(None, alias="secretString", description="New secret value as string")
|
|
24
|
+
secret_binary: str | None = Field(None, alias="secretBinary", description="New secret value as base64-encoded binary")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UpdateSecretMetadataBody(BaseModel):
|
|
28
|
+
"""Request body for updating a secret's metadata."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
31
|
+
|
|
32
|
+
description: str | None = Field(None, description="New description")
|
|
33
|
+
tags: dict[str, str] | None = Field(None, description="New tags (replaces existing)")
|
|
@@ -13,8 +13,8 @@ backend/websocket.py
|
|
|
13
13
|
backend/../ui/dist/favicon.png
|
|
14
14
|
backend/../ui/dist/favicon.svg
|
|
15
15
|
backend/../ui/dist/index.html
|
|
16
|
-
backend/../ui/dist/assets/index-
|
|
17
|
-
backend/../ui/dist/assets/index-
|
|
16
|
+
backend/../ui/dist/assets/index-CAZXHF4B.js
|
|
17
|
+
backend/../ui/dist/assets/index-DLd-xBZq.css
|
|
18
18
|
backend/../ui/dist/aws-icons/acm.svg
|
|
19
19
|
backend/../ui/dist/aws-icons/apigateway.svg
|
|
20
20
|
backend/../ui/dist/aws-icons/appsync.svg
|
|
@@ -67,6 +67,7 @@ backend/schemas/__init__.py
|
|
|
67
67
|
backend/schemas/dynamodb.py
|
|
68
68
|
backend/schemas/endpoints.py
|
|
69
69
|
backend/schemas/s3.py
|
|
70
|
+
backend/schemas/secretsmanager.py
|
|
70
71
|
backend/schemas/sqs.py
|
|
71
72
|
backend/schemas/tags.py
|
|
72
73
|
stackport.egg-info/PKG-INFO
|
|
@@ -99,8 +100,8 @@ tests/test_websocket.py
|
|
|
99
100
|
ui/dist/favicon.png
|
|
100
101
|
ui/dist/favicon.svg
|
|
101
102
|
ui/dist/index.html
|
|
102
|
-
ui/dist/assets/index-
|
|
103
|
-
ui/dist/assets/index-
|
|
103
|
+
ui/dist/assets/index-CAZXHF4B.js
|
|
104
|
+
ui/dist/assets/index-DLd-xBZq.css
|
|
104
105
|
ui/dist/aws-icons/acm.svg
|
|
105
106
|
ui/dist/aws-icons/apigateway.svg
|
|
106
107
|
ui/dist/aws-icons/appsync.svg
|
|
@@ -279,3 +279,230 @@ class TestGetSecretDetail:
|
|
|
279
279
|
assert resp.status_code == 200
|
|
280
280
|
data = resp.json()
|
|
281
281
|
assert data["secretValue"] == "sk-abc123def456"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class TestCreateSecret:
|
|
285
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
286
|
+
def test_create_secret_with_string(self, mock_get_client):
|
|
287
|
+
mock_sm = MagicMock()
|
|
288
|
+
mock_get_client.return_value = mock_sm
|
|
289
|
+
mock_sm.create_secret.return_value = {
|
|
290
|
+
"Name": "test-secret",
|
|
291
|
+
"ARN": "arn:aws:secretsmanager:us-east-1:000:secret:test-secret-abc",
|
|
292
|
+
"VersionId": "v1",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
resp = client.post(
|
|
296
|
+
"/api/secretsmanager/secrets",
|
|
297
|
+
json={
|
|
298
|
+
"name": "test-secret",
|
|
299
|
+
"description": "Test secret",
|
|
300
|
+
"secret_string": "my-secret-value",
|
|
301
|
+
"tags": {"env": "test"},
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
assert resp.status_code == 201
|
|
305
|
+
data = resp.json()
|
|
306
|
+
assert data["name"] == "test-secret"
|
|
307
|
+
assert data["arn"] == "arn:aws:secretsmanager:us-east-1:000:secret:test-secret-abc"
|
|
308
|
+
assert data["versionId"] == "v1"
|
|
309
|
+
|
|
310
|
+
mock_sm.create_secret.assert_called_once()
|
|
311
|
+
call_kwargs = mock_sm.create_secret.call_args[1]
|
|
312
|
+
assert call_kwargs["Name"] == "test-secret"
|
|
313
|
+
assert call_kwargs["Description"] == "Test secret"
|
|
314
|
+
assert call_kwargs["SecretString"] == "my-secret-value"
|
|
315
|
+
assert call_kwargs["Tags"] == [{"Key": "env", "Value": "test"}]
|
|
316
|
+
|
|
317
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
318
|
+
def test_create_secret_with_binary(self, mock_get_client):
|
|
319
|
+
mock_sm = MagicMock()
|
|
320
|
+
mock_get_client.return_value = mock_sm
|
|
321
|
+
mock_sm.create_secret.return_value = {
|
|
322
|
+
"Name": "binary-secret",
|
|
323
|
+
"ARN": "arn:binary",
|
|
324
|
+
"VersionId": "v1",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
import base64
|
|
328
|
+
binary_data = base64.b64encode(b"\x00\x01\x02").decode("utf-8")
|
|
329
|
+
|
|
330
|
+
resp = client.post(
|
|
331
|
+
"/api/secretsmanager/secrets",
|
|
332
|
+
json={"name": "binary-secret", "secret_binary": binary_data},
|
|
333
|
+
)
|
|
334
|
+
assert resp.status_code == 201
|
|
335
|
+
|
|
336
|
+
call_kwargs = mock_sm.create_secret.call_args[1]
|
|
337
|
+
assert call_kwargs["SecretBinary"] == b"\x00\x01\x02"
|
|
338
|
+
|
|
339
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
340
|
+
def test_create_secret_duplicate_name(self, mock_get_client):
|
|
341
|
+
mock_sm = MagicMock()
|
|
342
|
+
mock_get_client.return_value = mock_sm
|
|
343
|
+
mock_sm.exceptions.ResourceExistsException = type(
|
|
344
|
+
"ResourceExistsException", (Exception,), {}
|
|
345
|
+
)
|
|
346
|
+
mock_sm.create_secret.side_effect = mock_sm.exceptions.ResourceExistsException()
|
|
347
|
+
|
|
348
|
+
resp = client.post(
|
|
349
|
+
"/api/secretsmanager/secrets",
|
|
350
|
+
json={"name": "existing", "secret_string": "value"},
|
|
351
|
+
)
|
|
352
|
+
assert resp.status_code == 409
|
|
353
|
+
assert "already exists" in resp.json()["detail"]
|
|
354
|
+
|
|
355
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
356
|
+
def test_create_secret_no_value(self, mock_get_client):
|
|
357
|
+
resp = client.post(
|
|
358
|
+
"/api/secretsmanager/secrets",
|
|
359
|
+
json={"name": "test"},
|
|
360
|
+
)
|
|
361
|
+
assert resp.status_code == 400
|
|
362
|
+
assert "secret_string or secret_binary" in resp.json()["detail"]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class TestUpdateSecretValue:
|
|
366
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
367
|
+
def test_update_secret_value(self, mock_get_client):
|
|
368
|
+
mock_sm = MagicMock()
|
|
369
|
+
mock_get_client.return_value = mock_sm
|
|
370
|
+
mock_sm.put_secret_value.return_value = {
|
|
371
|
+
"ARN": "arn:test",
|
|
372
|
+
"Name": "test-secret",
|
|
373
|
+
"VersionId": "v2",
|
|
374
|
+
"VersionStages": ["AWSCURRENT"],
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
resp = client.put(
|
|
378
|
+
"/api/secretsmanager/secrets/test-secret/value",
|
|
379
|
+
json={"secret_string": "new-value"},
|
|
380
|
+
)
|
|
381
|
+
assert resp.status_code == 200
|
|
382
|
+
data = resp.json()
|
|
383
|
+
assert data["name"] == "test-secret"
|
|
384
|
+
assert data["versionId"] == "v2"
|
|
385
|
+
|
|
386
|
+
mock_sm.put_secret_value.assert_called_once()
|
|
387
|
+
call_kwargs = mock_sm.put_secret_value.call_args[1]
|
|
388
|
+
assert call_kwargs["SecretId"] == "test-secret"
|
|
389
|
+
assert call_kwargs["SecretString"] == "new-value"
|
|
390
|
+
|
|
391
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
392
|
+
def test_update_secret_value_not_found(self, mock_get_client):
|
|
393
|
+
mock_sm = MagicMock()
|
|
394
|
+
mock_get_client.return_value = mock_sm
|
|
395
|
+
mock_sm.exceptions.ResourceNotFoundException = type(
|
|
396
|
+
"ResourceNotFoundException", (Exception,), {}
|
|
397
|
+
)
|
|
398
|
+
mock_sm.put_secret_value.side_effect = (
|
|
399
|
+
mock_sm.exceptions.ResourceNotFoundException()
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
resp = client.put(
|
|
403
|
+
"/api/secretsmanager/secrets/nonexistent/value",
|
|
404
|
+
json={"secret_string": "value"},
|
|
405
|
+
)
|
|
406
|
+
assert resp.status_code == 404
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class TestUpdateSecretMetadata:
|
|
410
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
411
|
+
def test_update_description(self, mock_get_client):
|
|
412
|
+
mock_sm = MagicMock()
|
|
413
|
+
mock_get_client.return_value = mock_sm
|
|
414
|
+
|
|
415
|
+
resp = client.put(
|
|
416
|
+
"/api/secretsmanager/secrets/test-secret/metadata",
|
|
417
|
+
json={"description": "New description"},
|
|
418
|
+
)
|
|
419
|
+
assert resp.status_code == 200
|
|
420
|
+
mock_sm.update_secret.assert_called_once_with(
|
|
421
|
+
SecretId="test-secret", Description="New description"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
425
|
+
def test_update_tags(self, mock_get_client):
|
|
426
|
+
mock_sm = MagicMock()
|
|
427
|
+
mock_get_client.return_value = mock_sm
|
|
428
|
+
mock_sm.describe_secret.return_value = {
|
|
429
|
+
"ARN": "arn:test",
|
|
430
|
+
"Tags": [{"Key": "old", "Value": "tag"}],
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
resp = client.put(
|
|
434
|
+
"/api/secretsmanager/secrets/test-secret/metadata",
|
|
435
|
+
json={"tags": {"new": "tag"}},
|
|
436
|
+
)
|
|
437
|
+
assert resp.status_code == 200
|
|
438
|
+
mock_sm.untag_resource.assert_called_once()
|
|
439
|
+
mock_sm.tag_resource.assert_called_once()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class TestDeleteSecret:
|
|
443
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
444
|
+
def test_delete_secret_with_recovery(self, mock_get_client):
|
|
445
|
+
mock_sm = MagicMock()
|
|
446
|
+
mock_get_client.return_value = mock_sm
|
|
447
|
+
mock_sm.delete_secret.return_value = {
|
|
448
|
+
"ARN": "arn:test",
|
|
449
|
+
"Name": "test-secret",
|
|
450
|
+
"DeletionDate": CHANGED,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
resp = client.delete("/api/secretsmanager/secrets/test-secret")
|
|
454
|
+
assert resp.status_code == 200
|
|
455
|
+
data = resp.json()
|
|
456
|
+
assert data["name"] == "test-secret"
|
|
457
|
+
assert data["deletionDate"] == CHANGED.isoformat()
|
|
458
|
+
|
|
459
|
+
call_kwargs = mock_sm.delete_secret.call_args[1]
|
|
460
|
+
assert call_kwargs["RecoveryWindowInDays"] == 7
|
|
461
|
+
assert "ForceDeleteWithoutRecovery" not in call_kwargs
|
|
462
|
+
|
|
463
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
464
|
+
def test_delete_secret_force(self, mock_get_client):
|
|
465
|
+
mock_sm = MagicMock()
|
|
466
|
+
mock_get_client.return_value = mock_sm
|
|
467
|
+
mock_sm.delete_secret.return_value = {
|
|
468
|
+
"ARN": "arn:test",
|
|
469
|
+
"Name": "test-secret",
|
|
470
|
+
"DeletionDate": CHANGED,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
resp = client.delete("/api/secretsmanager/secrets/test-secret?force=true")
|
|
474
|
+
assert resp.status_code == 200
|
|
475
|
+
|
|
476
|
+
call_kwargs = mock_sm.delete_secret.call_args[1]
|
|
477
|
+
assert call_kwargs["ForceDeleteWithoutRecovery"] is True
|
|
478
|
+
assert "RecoveryWindowInDays" not in call_kwargs
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class TestRestoreSecret:
|
|
482
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
483
|
+
def test_restore_secret(self, mock_get_client):
|
|
484
|
+
mock_sm = MagicMock()
|
|
485
|
+
mock_get_client.return_value = mock_sm
|
|
486
|
+
mock_sm.restore_secret.return_value = {
|
|
487
|
+
"ARN": "arn:test",
|
|
488
|
+
"Name": "test-secret",
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
resp = client.post("/api/secretsmanager/secrets/test-secret/restore")
|
|
492
|
+
assert resp.status_code == 200
|
|
493
|
+
data = resp.json()
|
|
494
|
+
assert data["name"] == "test-secret"
|
|
495
|
+
|
|
496
|
+
@patch("backend.routes.secretsmanager.get_client")
|
|
497
|
+
def test_restore_secret_not_deleted(self, mock_get_client):
|
|
498
|
+
mock_sm = MagicMock()
|
|
499
|
+
mock_get_client.return_value = mock_sm
|
|
500
|
+
|
|
501
|
+
error = Exception("Invalid request")
|
|
502
|
+
error.response = {
|
|
503
|
+
"Error": {"Code": "InvalidRequestException", "Message": "Secret not in deleted state"}
|
|
504
|
+
}
|
|
505
|
+
mock_sm.restore_secret.side_effect = error
|
|
506
|
+
|
|
507
|
+
resp = client.post("/api/secretsmanager/secrets/test-secret/restore")
|
|
508
|
+
assert resp.status_code == 400
|