Repository navigation
feat(storage): cut over first-file uploads to partner-uploads #3732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ec956ff
290a5a0
06212b6
514ad7c
485538a
6859ff3
70aca49
3c3aa00
dcaea7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,246 @@ | ||
| """First-file partner upload persistence (SEL-549 / partner-uploads). | ||
|
|
||
| Uploads land in the private ``partner-uploads`` bucket under a tenant-scoped | ||
| path. Metadata is recorded in ``public.partner_files``; ``fsma.manifest_blobs`` | ||
| keeps the SHA-256 + ``storage_url`` pointer and leaves ``raw_bytes`` NULL when | ||
| object storage is configured. | ||
|
|
||
| Synthetic / test fixtures only until SEL-590 clears. Do not invent partner | ||
| payloads; callers pass bytes they already hold. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| import os | ||
| import logging | ||
| import re | ||
| from typing import Any, Dict, Optional | ||
| from uuid import uuid4 | ||
|
|
||
| from fastapi import HTTPException | ||
| from sqlalchemy import text | ||
|
|
||
| logger = logging.getLogger("partner_file_storage") | ||
|
|
||
| PARTNER_UPLOADS_BUCKET = "partner-uploads" | ||
|
|
||
| def allow_manifest_bytea_fallback() -> bool: | ||
| """Permit raw_bytes fallback only in non-production with an explicit flag. | ||
|
|
||
| Production always fail-closes when object storage is not configured so | ||
| partner CSV payloads cannot silently land in Postgres. Local/test must set | ||
| ``REGENGINE_ALLOW_MANIFEST_BYTEA_FALLBACK=1`` to opt into the legacy path. | ||
| """ | ||
| from shared.env import is_production | ||
|
|
||
| if is_production(): | ||
| return False | ||
| flag = os.getenv("REGENGINE_ALLOW_MANIFEST_BYTEA_FALLBACK", "").strip().lower() | ||
| return flag in {"1", "true", "yes", "on"} | ||
|
|
||
|
|
||
| _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+") | ||
|
|
||
|
|
||
| def tenant_object_key( | ||
| *, | ||
| tenant_id: str, | ||
| engagement_id: str, | ||
| filename: str, | ||
| object_id: Optional[str] = None, | ||
| ) -> str: | ||
| """Build ``tenants/{tenant}/engagements/{engagement}/{uuid}-{filename}``.""" | ||
| from .s3_utils import safe_s3_key_component, _validate_s3_key | ||
|
|
||
| oid = object_id or str(uuid4()) | ||
| safe_name = safe_s3_key_component(filename, fallback="upload.csv") | ||
| key = ( | ||
| f"tenants/{safe_s3_key_component(tenant_id, fallback='tenant')}" | ||
| f"/engagements/{safe_s3_key_component(engagement_id, fallback='engagement')}" | ||
| f"/{safe_s3_key_component(oid, fallback='object')}-{safe_name}" | ||
| ) | ||
| _validate_s3_key(key) | ||
| return key | ||
|
|
||
|
|
||
| def persist_partner_upload( | ||
| db, | ||
| *, | ||
| tenant_id: str, | ||
| engagement_id: str, | ||
| content: bytes, | ||
| filename: str, | ||
| content_type: Optional[str], | ||
| uploaded_by: Optional[str], | ||
| ingestion_run_id: str, | ||
| ) -> Dict[str, Any]: | ||
| """Store bytes in partner-uploads and write metadata + blob pointer. | ||
|
|
||
| Production fail-closes with HTTP 503 when object storage is not configured. | ||
| Local/test may set ``REGENGINE_ALLOW_MANIFEST_BYTEA_FALLBACK=1`` to keep the | ||
| legacy ``manifest_blobs.raw_bytes`` path. | ||
|
|
||
| Returns a dict with ``partner_file_id``, ``storage_url``, ``storage_path``, | ||
| ``sha256``, and ``used_object_storage``. | ||
| """ | ||
| from .remediation.audit_utils import audit_event | ||
| from .s3_utils import is_object_storage_configured, put_bytes | ||
|
|
||
| if not content: | ||
| raise HTTPException(status_code=400, detail="Empty upload") | ||
|
|
||
| sha256 = hashlib.sha256(content).hexdigest() | ||
| ctype = content_type or "text/csv" | ||
| partner_file_id = str(uuid4()) | ||
| storage_path = tenant_object_key( | ||
| tenant_id=tenant_id, | ||
| engagement_id=engagement_id, | ||
| filename=filename, | ||
| object_id=partner_file_id, | ||
| ) | ||
| used_object_storage = False | ||
| storage_url: Optional[str] = None | ||
| raw_bytes_param: Optional[bytes] = None | ||
|
|
||
| if is_object_storage_configured(): | ||
| storage_url = put_bytes( | ||
| PARTNER_UPLOADS_BUCKET, | ||
| storage_path, | ||
| content, | ||
| ctype, | ||
| ) | ||
| used_object_storage = True | ||
| raw_bytes_param = None # pointer-only row; bytes live in the bucket | ||
|
Comment on lines
+106
to
+114
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Object upload can leave an orphan if a later write fails.
The retention date is derived from Add a best-effort delete of the object when the DB writes in this function fail. Also add a periodic reconciliation job or a bucket lifecycle rule for objects that have no
|
||
| db.execute( | ||
| # nosemgrep: avoid-sqlalchemy-text — parameterized with :param | ||
| text( | ||
| """ | ||
| INSERT INTO public.partner_files ( | ||
| id, tenant_id, engagement_id, storage_bucket, storage_path, | ||
| content_sha256, byte_size, content_type, original_filename, | ||
| status | ||
| ) VALUES ( | ||
| CAST(:id AS uuid), | ||
| CAST(:tenant_id AS uuid), | ||
| CAST(:engagement_id AS uuid), | ||
| :bucket, | ||
| :storage_path, | ||
| :sha256, | ||
| :byte_size, | ||
| :content_type, | ||
| :filename, | ||
| 'uploaded' | ||
| ) | ||
| """ | ||
| ), | ||
| { | ||
| "id": partner_file_id, | ||
| "tenant_id": tenant_id, | ||
| "engagement_id": engagement_id, | ||
| "bucket": PARTNER_UPLOADS_BUCKET, | ||
| "storage_path": storage_path, | ||
| "sha256": sha256, | ||
| "byte_size": len(content), | ||
| "content_type": ctype, | ||
| "filename": filename, | ||
| }, | ||
| ) | ||
| else: | ||
| if not allow_manifest_bytea_fallback(): | ||
| logger.error( | ||
| "partner_upload_storage_unconfigured tenant=%s run=%s", | ||
| tenant_id, | ||
| ingestion_run_id, | ||
| ) | ||
| raise HTTPException( | ||
| status_code=503, | ||
| detail=( | ||
| "Partner object storage is not configured. " | ||
| "Refusing to store upload bytes in the database." | ||
| ), | ||
| ) | ||
| # Explicit local/test fallback only. | ||
| raw_bytes_param = content | ||
| storage_url = None | ||
|
|
||
| db.execute( | ||
| # nosemgrep: avoid-sqlalchemy-text — parameterized with :param | ||
| text( | ||
| """ | ||
| INSERT INTO fsma.manifest_blobs | ||
| (ingestion_run_id, tenant_id, raw_bytes, sha256, | ||
| original_filename, content_type, uploaded_by, storage_url) | ||
|
Comment on lines
+171
to
+173
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When ingestion runs as the restricted Useful? React with 👍 / 👎. |
||
| VALUES | ||
| (CAST(:run_id AS uuid), CAST(:tenant_id AS uuid), :raw_bytes, | ||
| :sha256, :filename, :content_type, :uploaded_by, :storage_url) | ||
| """ | ||
| ), | ||
| { | ||
| "run_id": ingestion_run_id, | ||
| "tenant_id": tenant_id, | ||
| "raw_bytes": raw_bytes_param, | ||
| "sha256": sha256, | ||
| "filename": filename, | ||
| "content_type": ctype, | ||
| "uploaded_by": uploaded_by, | ||
| "storage_url": storage_url, | ||
| }, | ||
| ) | ||
|
|
||
| audit_event( | ||
| db, | ||
| tenant_id=tenant_id, | ||
| event_type="partner_file.upload", | ||
| action="partner_file.upload", | ||
| severity="info", | ||
| actor_id=uploaded_by, | ||
| resource_type="partner_file" if used_object_storage else "manifest_blob", | ||
| resource_id=partner_file_id if used_object_storage else ingestion_run_id, | ||
| metadata={ | ||
| "ingestion_run_id": ingestion_run_id, | ||
| "engagement_id": engagement_id, | ||
| "storage_bucket": PARTNER_UPLOADS_BUCKET if used_object_storage else None, | ||
| "storage_path": storage_path if used_object_storage else None, | ||
| "storage_url": storage_url, | ||
| "content_sha256": sha256, | ||
| "byte_size": len(content), | ||
| "original_filename": filename, | ||
| "used_object_storage": used_object_storage, | ||
| }, | ||
| ) | ||
|
|
||
| return { | ||
| "partner_file_id": partner_file_id if used_object_storage else None, | ||
| "storage_url": storage_url, | ||
| "storage_path": storage_path if used_object_storage else None, | ||
| "sha256": sha256, | ||
| "used_object_storage": used_object_storage, | ||
| } | ||
|
|
||
|
|
||
| def load_bytes_from_storage_url(storage_url: str) -> bytes: | ||
| """Fetch object bytes for an ``s3://bucket/key`` storage_url.""" | ||
| from .s3_utils import get_raw_document | ||
|
|
||
| if not storage_url.startswith("s3://"): | ||
| raise HTTPException(status_code=500, detail="Unsupported storage_url scheme") | ||
| without = storage_url[len("s3://") :] | ||
| bucket, _, key = without.partition("/") | ||
| if not bucket or not key: | ||
| raise HTTPException(status_code=500, detail="Malformed storage_url") | ||
| doc = get_raw_document(key=key, bucket=bucket) | ||
| return doc["content"] | ||
|
|
||
|
|
||
| def resolve_manifest_raw_bytes( | ||
| *, | ||
| raw_bytes: Any, | ||
| storage_url: Optional[str], | ||
| ) -> bytes: | ||
| """Return manifest bytes from bytea or, when null, from storage_url.""" | ||
| if raw_bytes is not None: | ||
| return bytes(raw_bytes) | ||
| if storage_url: | ||
| return load_bytes_from_storage_url(storage_url) | ||
| raise HTTPException(status_code=404, detail="Manifest payload not found") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. S3 load skips hash checkMedium Severity
Additional Locations (1)Triggered by project rule: Cursor Bugbot Review Guidelines Reviewed by Cursor Bugbot for commit 06212b6. Configure here.
Comment on lines
+222
to
+246
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate the storage pointer and the fetched bytes on the read path.
Pass Update the call in "raw_bytes": resolve_manifest_raw_bytes(
raw_bytes=row[2],
storage_url=row[3],
tenant_id=tenant_id,
expected_sha256=row[0],
),Also update 🛡️ Proposed fix
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -109,53 +109,21 @@ def ingest_manifest( | |
| ) | ||
| run_id = store.create_ingestion_run(run) | ||
|
|
||
| db.execute( | ||
| # nosemgrep: avoid-sqlalchemy-text — parameterized with :param | ||
| text( | ||
| """ | ||
| INSERT INTO fsma.manifest_blobs | ||
| (ingestion_run_id, tenant_id, raw_bytes, sha256, | ||
| original_filename, content_type, uploaded_by) | ||
| VALUES | ||
| (CAST(:run_id AS uuid), CAST(:tenant_id AS uuid), :raw_bytes, | ||
| :sha256, :filename, :content_type, :uploaded_by) | ||
| """ | ||
| ), | ||
| { | ||
| "run_id": run_id, | ||
| "tenant_id": tenant_id, | ||
| "raw_bytes": raw_bytes, | ||
| "sha256": sha256, | ||
| "filename": filename, | ||
| "content_type": content_type, | ||
| "uploaded_by": uploaded_by, | ||
| }, | ||
| ) | ||
| # First-file cutover: prefer private partner-uploads (pointer-only | ||
| # manifest_blobs + partner_files metadata + audit). Falls back to | ||
| # bytea when object storage is not configured (local/dev). | ||
| from ..partner_file_storage import persist_partner_upload | ||
|
|
||
| # Optional off-box mirror; the bytea row remains the authoritative copy. | ||
| try: | ||
| from ..s3_utils import RAW_INGEST_BUCKET, is_object_storage_configured, put_bytes | ||
|
|
||
| if is_object_storage_configured(): | ||
| storage_url = put_bytes( | ||
| RAW_INGEST_BUCKET, | ||
| f"remediation/{tenant_id}/{run_id}/{filename}", | ||
| raw_bytes, | ||
| content_type or "text/csv", | ||
| ) | ||
| db.execute( | ||
| # nosemgrep: avoid-sqlalchemy-text — parameterized with :param | ||
| text( | ||
| """ | ||
| UPDATE fsma.manifest_blobs SET storage_url = :storage_url | ||
| WHERE ingestion_run_id = CAST(:run_id AS uuid) | ||
| AND tenant_id = CAST(:tenant_id AS uuid) | ||
| """ | ||
| ), | ||
| {"storage_url": storage_url, "run_id": run_id, "tenant_id": tenant_id}, | ||
| ) | ||
| except Exception as exc: # pragma: no cover — mirror is best-effort | ||
| logger.warning("manifest_blob_mirror_failed run=%s error=%s", run_id, exc) | ||
| persist_partner_upload( | ||
| db, | ||
| tenant_id=tenant_id, | ||
| engagement_id=run_id, | ||
| content=raw_bytes, | ||
| filename=filename, | ||
| content_type=content_type, | ||
| uploaded_by=uploaded_by, | ||
| ingestion_run_id=run_id, | ||
| ) | ||
|
|
||
| seen_fingerprints: Dict[str, int] = {} | ||
| records: List[Dict[str, Any]] = [] | ||
|
|
@@ -787,12 +755,14 @@ def release_record(db, *, tenant_id: str, record: Dict[str, Any], source_meta: D | |
|
|
||
|
|
||
| def get_run_meta(db, *, tenant_id: str, run_id: str) -> Dict[str, Any]: | ||
| from ..partner_file_storage import resolve_manifest_raw_bytes | ||
|
|
||
| row = db.execute( | ||
| # nosemgrep: avoid-sqlalchemy-text — parameterized with :param | ||
| text( | ||
| """ | ||
| SELECT b.sha256, b.original_filename, b.raw_bytes, r.status, | ||
| r.record_count, r.accepted_count, r.rejected_count | ||
| SELECT b.sha256, b.original_filename, b.raw_bytes, b.storage_url, | ||
| r.status, r.record_count, r.accepted_count, r.rejected_count | ||
| FROM fsma.manifest_blobs b | ||
| JOIN fsma.ingestion_runs r ON r.id = b.ingestion_run_id | ||
| WHERE b.ingestion_run_id = CAST(:run_id AS uuid) | ||
|
|
@@ -807,11 +777,11 @@ def get_run_meta(db, *, tenant_id: str, run_id: str) -> Dict[str, Any]: | |
| return { | ||
| "sha256": row[0], | ||
| "filename": row[1], | ||
| "raw_bytes": bytes(row[2]), | ||
| "run_status": row[3], | ||
| "record_count": row[4], | ||
| "accepted_count": row[5], | ||
| "rejected_count": row[6], | ||
| "raw_bytes": resolve_manifest_raw_bytes(raw_bytes=row[2], storage_url=row[3]), | ||
| "run_status": row[4], | ||
| "record_count": row[5], | ||
| "accepted_count": row[6], | ||
| "rejected_count": row[7], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Metadata paths require object bytesMedium Severity
Reviewed by Cursor Bugbot for commit 06212b6. Configure here. |
||
| } | ||
|
|
||
|
|
||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enable the fallback in the restricted-role CSV step.
This variable applies only to the
Run live-DB suitestep. The laterVerify ingestion and exports with the restricted runtime rolestep does not inherit it. That step runs CSV upload tests that call/api/v1/ingest/csvand require HTTP 200. With object storage absent and this flag unset,persist_partner_uploadraises HTTP 503, so the required CI step fails. (raw.githubusercontent.com)Add the flag to the restricted-role step or move it to the
live-db-suitejob environment. KeepREGENGINE_ENV: testfor the non-production guard.