-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrest_api_utils.py
More file actions
231 lines (189 loc) · 10.3 KB
/
Copy pathrest_api_utils.py
File metadata and controls
231 lines (189 loc) · 10.3 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
231
import json
import re
import pymysql
from classifier import varDump
from auth_utils import (plan_parent_lookups, referenced_parent_columns,
resolve_parent_lookups)
#
# json response utility function
#
def compose_rest_response(status_code, body='', http_message=''):
#
# Compose AWS Lambda proxy response format
# https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format
#
print(f"HTTP Status Code: {status_code}")
lambda_rest_api_response = {
'isBase64Encoded': False,
'statusCode': status_code,
'headers': {'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'body, Content-Type, Access-Control-Allow-Headers, Access-Control-Allow-Origin, Access-Control-Allow-Methods',
'Access-Control-Allow-Methods': 'PUT, GET, POST, DELETE, OPTIONS',
}
}
# On an error status the body IS http_message — whatever the caller passed as
# `body` is discarded. http_message is usually a string; the 409 path
# (req #3059) passes a dict, which json.dumps renders as a JSON OBJECT so the
# client reads errno and constraint instead of parsing prose.
if status_code not in (200, 201, 204):
print(f"Error message inserted into body. {body} : {http_message}")
body = http_message
#
# json encode body, insert into response
#
if body is not None:
lambda_rest_api_response['body'] = json.dumps(body)
else:
print('body is empty')
#varDump(lambda_rest_api_response, 'Lambda proxy response')
return lambda_rest_api_response
# ---------------------------------------------------------------------------
# Parent-reference write authorization (req #3122 junctions, req #3125 creator
# tables)
# ---------------------------------------------------------------------------
def parent_reference_guard(conn, table, bodies, authenticated_user, method,
require_scope=True):
"""403/400 response when a write names a parent the caller does not own, else None.
Covers BOTH halves of the rule, because they are the same check asked of two
different registries:
* a **junction** table with no `creator_fk`, where the parent references ARE
the row's ownership (req #3122); and
* a **`creator_fk`-bearing** table, where the row's own owner is settled and
it is the rows it POINTS AT that went unchecked (req #3125). Scoping a row
to its creator says nothing about what it references — an attacker's own,
correctly-scoped row carrying an `ON DELETE RESTRICT` `*_fk` at a victim's
parent makes that parent permanently undeletable by its owner.
Shared by `rest_post` and `rest_put` because BOTH can point a row at another
creator, by different routes: POST has no WHERE clause to scope, and PUT's
WHERE only proves ownership BEFORE the update while its SET clause can rewrite
the reference afterwards. Guarding one verb and not the other simply moves the
hole.
Returns BEFORE opening a cursor whenever there is nothing to look up — a table
in neither registry, an unauthenticated call, or (the case req #3125 makes
common) a registered table whose body names no parent at all. Opening one
unconditionally is not merely wasteful: it moves the request's first cursor
acquisition ahead of the INSERT, so a connection that fails on `cursor()`
fails HERE — with nothing written and nothing to roll back. Req #3125 widened
the exposure to `tasks`/`areas`/`requirements`, where most writes name no
parent: `PUT /darwin/tasks [{"id": 5, "done": 1}]` must still reach the UPDATE
with its cursor unopened.
Held by the `ExplodingConn` cases in `tests/test_unit_junction_scoping.py`,
which cover all four no-work routes including the 400 refusal. NOT by
`tests/test_unit_error_detail_wiring.py`, whose comments used to claim it:
every case there passes `authenticated_user=None`, so this function returns
on its first line and is never exercised.
Note that on a write which DOES name a parent, this SELECT is now legitimately
the first cursor acquisition, so a dead connection reports "parent ownership
check failed" rather than "POST failed". Nothing is written either way.
A failure of the CHECK ITSELF is a 500, never a pass. It runs before any
write, so refusing costs nothing; treating an unreadable parent table as
"probably fine" would turn one broken query into an authorization bypass.
"""
if authenticated_user is None or not referenced_parent_columns(table):
return None
# Planning is pure — it decides WHAT to ask without asking, so a malformed
# body and a body with no references are both answered with no cursor at all.
lookups, refusal = plan_parent_lookups(table, bodies,
require_scope=require_scope)
if refusal is not None:
status, message = refusal
return compose_rest_response(status, '', message)
if not lookups:
return None
try:
with conn.cursor() as cursor:
verdict = resolve_parent_lookups(cursor, table, lookups,
authenticated_user)
except pymysql.Error as e:
errno, detail = error_detail(e)
errorMsg = (f"HTTP {method} parent ownership check failed: "
f"{errno} {detail}")
print(errorMsg)
return compose_rest_response(500, '', errorMsg)
if verdict is None:
return None
status, message = verdict
return compose_rest_response(status, '', message)
# ---------------------------------------------------------------------------
# Integrity violations -> HTTP 409 CONFLICT (req #3059)
# ---------------------------------------------------------------------------
#
# Before this, EVERY pymysql failure was a 500 carrying the raw driver message,
# so a client could not tell "you picked a taken name" from "the database is
# broken" without regex-matching prose. Three errnos mean the request conflicts
# with data that already exists, and only those three get the 409:
#
# 1062 Duplicate entry ... for key ... UNIQUE / PRIMARY KEY collision
# 1451 Cannot delete or update a parent row FK RESTRICT protecting children
# 1452 Cannot add or update a child row FK pointing at a row that is gone
#
# The line is deliberate, not a shortlist to grow casually: a 409 tells the
# caller that retrying with different data can succeed. A missing column (1054),
# a NOT NULL with no default (1364), a lost connection (2013) — none of those
# keep that promise, so they stay 500s.
INTEGRITY_ERRNOS = frozenset({1062, 1451, 1452})
# `... for key 'instructions.uq_instructions_name'` — MySQL 8 qualifies the key
# name with its table, 5.7 does not. Anchored at end-of-message and quote-free in
# the group so a duplicated VALUE that itself contains "for key '...'" cannot be
# mistaken for the key.
_DUP_KEY_RE = re.compile(r"for key '([^']+)'\s*$")
# ``... (`darwin_dev`.`areas`, CONSTRAINT `areas_ibfk_1` FOREIGN KEY ...)``
_FK_CONSTRAINT_RE = re.compile(r"CONSTRAINT `([^`]+)`")
def error_detail(exc):
"""(errno, message) from a pymysql.Error, tolerant of a short args tuple.
All eleven error-formatting sites in the CRUD modules — rest_post (5),
rest_get_table (2), rest_put, rest_delete (2), rest_get_database — used to
read `f"...: {e.args[0]} {e.args[1]}"` directly. pymysql raises ONE-arg
errors from its own plumbing (`ProgrammingError("Cursor closed")`,
`Error("Already closed")`), so that raised IndexError from INSIDE the
`except pymysql.Error` block; `lambda_handler`'s blanket `except Exception`
then swallowed it into a 503 SERVICE_UNAVAILABLE naming nothing. On a GET
that also cost a round trip — darwin-mcp retries idempotent GETs once on 503.
Going through here keeps the 2-arg message byte-identical (verified per-site)
and turns the short-args case into a real 500 that names the failure.
`tests/test_unit_error_detail_wiring.py` holds the sites to this; the pure
function is covered in `tests/test_unit_conflict.py`.
"""
errno = exc.args[0] if exc.args else None
message = exc.args[1] if len(exc.args) > 1 else ''
return errno, message
def integrity_errno(exc):
"""The MySQL errno of `exc` when it is an integrity violation, else None.
Total over every shape pymysql raises, including no args at all — this is
called from inside an `except` block, where anything it raised would escape
as a 503 that names nothing.
"""
errno, _ = error_detail(exc)
try:
return errno if errno in INTEGRITY_ERRNOS else None
except TypeError:
return None # unhashable args[0] — not an errno by any reading
def constraint_name(errno, message):
"""The index / FK name the violation names, unqualified. None when absent.
MySQL 8 reports a duplicate key as `table.index`; the schema declares plain
`index`. Stripping the qualifier lets a consumer compare against the name it
can actually read in the DDL — and nothing is lost, because the response
carries `table` separately, sourced from the handler rather than the message.
"""
match = (_DUP_KEY_RE if errno == 1062 else _FK_CONSTRAINT_RE).search(message or '')
return match.group(1).rsplit('.', 1)[-1] if match else None
def compose_conflict_response(table, exc, error_message):
"""409 CONFLICT for a MySQL integrity violation, with a structured body.
Body shape:
{"error": "CONFLICT", "errno": 1062,
"constraint": "uq_instructions_name", "table": "instructions",
"message": "HTTP PUT SQL FAILED: 1062 Duplicate entry 'x' for key ..."}
`error_message` is the exact string the 500 path used to put on the wire and
is echoed verbatim under `message`. That is a compatibility promise, not
padding: darwin-mcp's client._translate and any log grep written against the
old contract keep working while they move over to `errno`.
"""
errno, detail = error_detail(exc)
return compose_rest_response(409, '', {
'error': 'CONFLICT',
'errno': errno,
'constraint': constraint_name(errno, detail),
'table': table,
'message': error_message,
})