Repository navigation
Expand file tree
/
Copy pathhandler.py
More file actions
230 lines (185 loc) · 8.61 KB
/
Copy pathhandler.py
File metadata and controls
230 lines (185 loc) · 8.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import os
import json
import re
import pymysql
from classifier import varDump, pretty_print_sql
from rest_api_utils import compose_rest_response
from db_connection import get_connection
from rest_get_database import rest_get_database
from rest_get_table import rest_get_table
from rest_put import rest_put
from rest_post import rest_post
from rest_delete import rest_delete
from auth_utils import (get_authenticated_user, CREATOR_FK_TABLES,
JUNCTION_OWNERSHIP, PROFILE_TABLE)
import pipeline2_compose
# req #3367 — the ONE non-generic route (remediation B, composing form).
# `pipeline_compose` / `pipeline_compose_epic` are not real tables; they are
# reserved route names dispatched to `pipeline2_compose.py` BEFORE the generic
# `{database}/{table}` gateway ever sees them. GET only, `id` as a query
# string parameter — same grammar as every other single-row lookup
# (`GET /darwin/pipeline_compose?id=5`), so the existing darwin-mcp REST
# client needs no new URL-building code, only a new response-shape method.
PIPELINE_COMPOSE_ROUTES = {
'pipeline_compose': pipeline2_compose.compose_pipeline2,
'pipeline_compose_epic': pipeline2_compose.compose_pipeline2_epic,
}
#
# HTTP Method const values
#
get_method = 'GET'
post_method = 'POST'
put_method = 'PUT'
delete_method = 'DELETE'
options_method = 'OPTIONS'
db_names = set(os.environ['db_name'].split(','))
print('RestApi-MySql-Lambda init code executing.')
SAFE_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
# The same integer grammar auth_utils._reference_value enforces for a body-
# supplied id — MySQL's, not Python's (req #3122's canonicalization rule).
# QSP values arrive as strings, so this is the ONE place that grammar needs
# restating for a query-string id rather than a body field.
_ID_QSP_RE = re.compile(r'^[+-]?[0-9]+$')
def _parse_id_qsp(event):
"""The `id` query-string parameter as an int, or None if absent/invalid."""
qsp = event.get('queryStringParameters') or {}
raw = qsp.get('id')
if raw is None or not _ID_QSP_RE.match(raw.strip()):
return None
return int(raw.strip())
def parse_path(path):
#
# Split first level of path into database.
# Second level of path into table.
# Save aside the database/table connection as applicable.
#
split_path = path[1:].split('/')
database = split_path[0]
table = split_path[1] if len(split_path) > 1 else ''
if table and not SAFE_NAME_RE.match(table):
return {'path': path, 'database': database, 'table': '', 'conn': '', 'error': f"Invalid table name: {table}"}
conn = get_connection(database) if database in db_names else ''
#varDump({'path': path, 'database': database, 'table': table}, 'parse_path results', 'json')
return {'path': path, 'database': database, 'table': table, 'conn': conn}
#FAAS ENTRY POINT: the AWS Lambda function is configured to call this function by name.
def lambda_handler(event, context):
db_info = None
try:
#varDump(event, 'lambda_handler dump event')
#varDump(context, 'lambda_handler context')
path = event.get('path')
print(f"Lambda Handler Invoked for {path}.{event['httpMethod']}")
if path:
db_info = parse_path(path)
else:
return compose_rest_response(400, '', f"No path provided")
if 'error' in db_info:
return compose_rest_response(400, '', db_info['error'])
if db_info['database'] in db_names:
response = rest_api_from_table(event, db_info)
else:
response = compose_rest_response(404, '', f"URL/path not found: {path}")
return response
except pymysql.OperationalError as e:
code = e.args[0] if e.args else 0
if code == 1040:
print(f"DB_CONNECTION_LIMIT ({code}): {e}")
return compose_rest_response(503, '', 'DB_CONNECTION_LIMIT')
print(f"DB_ERROR ({code}): {e}")
return compose_rest_response(503, '', 'DB_UNAVAILABLE')
except Exception as e:
print(f"UNHANDLED_EXCEPTION {type(e).__name__}: {e}")
return compose_rest_response(503, '', 'SERVICE_UNAVAILABLE')
finally:
if db_info and db_info.get('conn'):
try:
db_info['conn'].close()
except Exception:
pass
def rest_api_from_table(event, db_info):
#varDump(db_info, "db_info at start of rest_api_from_table call")
database = db_info['database']
table = db_info['table']
conn = db_info['conn']
http_method = event.get('httpMethod')
if not event:
print('no event')
return compose_rest_response(500, '', 'REST API call received with no event')
if not conn:
print('no conn')
return compose_rest_response(500, '', 'REST API call, no database connection')
if not http_method:
print('No HTTP method')
return compose_rest_response(500, '', 'REST API call received with no HTTP method')
# OPTIONS (CORS preflight) — no auth required
if http_method == options_method:
return compose_rest_response(200, '', '')
# Extract authenticated user from Cognito authorizer claims
authenticated_user = get_authenticated_user(event)
# req #3367 — the composing route, dispatched BEFORE the generic gateway
# (these are not real tables, so DESC/CRUD below would only ever fail).
if table in PIPELINE_COMPOSE_ROUTES:
return _rest_pipeline_compose(table, conn, event, http_method,
authenticated_user)
# Block unauthenticated access to user-scoped tables.
#
# JUNCTION_OWNERSHIP tables join in (req #3122). Their scoping is derived
# from a parent's creator_fk, so with no identity there is nothing to derive
# it FROM — every WHERE clause below would simply omit the predicate and hand
# back every user's rows. API Gateway's Cognito authorizer should mean this
# never fires; it is the second lock, for the day the authorizer is
# misconfigured on one route.
if (table in CREATOR_FK_TABLES or table == PROFILE_TABLE
or table in JUNCTION_OWNERSHIP):
if authenticated_user is None:
print(f'Auth: unauthenticated request to user table {table}')
return compose_rest_response(403, '', 'FORBIDDEN')
body = None
if event['body'] is not None:
body = json.loads(event['body'])
#
# FILTER BY HTTP METHOD
#
if http_method == put_method:
# PUT Method
return rest_put(put_method, conn, database, table, body, authenticated_user)
elif http_method == get_method:
# GET Method
if table:
return rest_get_table(get_method, conn, database, table, event, authenticated_user)
else:
return rest_get_database(get_method, conn, database)
elif http_method == post_method:
# POST Method
return rest_post(post_method, conn, database, table, body, authenticated_user)
elif http_method == delete_method:
# DELETE Method
return rest_delete(delete_method, conn, database, table, body, authenticated_user)
def _rest_pipeline_compose(table, conn, event, http_method, authenticated_user):
"""req #3367 — GET-only, `id` as a query-string parameter, same shape as
every other single-row lookup. Not a real table, so PUT/POST/DELETE and a
missing/malformed `id` are refused here rather than reaching pymysql."""
if http_method != get_method:
return compose_rest_response(
400, '', f"{table} is a read-only composed route; {http_method} not allowed")
# Same gate the generic CREATOR_FK_TABLES check gives every other
# user-scoped table — this route names none of the real table names that
# check matches on, so it needs its own.
if authenticated_user is None:
print(f'Auth: unauthenticated request to composed route {table}')
return compose_rest_response(403, '', 'FORBIDDEN')
row_id = _parse_id_qsp(event)
if row_id is None:
return compose_rest_response(400, '', f"{table}: a valid integer 'id' query "
"parameter is required")
try:
composed = PIPELINE_COMPOSE_ROUTES[table](conn, row_id, authenticated_user)
except ValueError as e:
# A data-integrity issue (epic names a pipeline_fk that does not
# resolve for this creator) — real but not the caller's fault to fix
# by retrying, so 500 rather than 400/404.
print(f"{table} data integrity error: {e}")
return compose_rest_response(500, '', str(e))
if composed is None:
return compose_rest_response(404, '', 'NOT FOUND')
return compose_rest_response(200, composed)