asset-manager-service — business features

Every file the platform stores: upload tickets, the asset registry, and public delivery over cdn.useoper.com · ← platform hub · entry points verified against tools/feature-docs/out/asset-manager-service.json

1. What it is

asset-manager-service is the platform's file desk. No other service touches the storage bucket directly: they ask asset-manager for a short-lived upload ticket, the bytes go straight from the uploader to S3, and asset-manager keeps the record of what was stored, for which company, and whether it is public or private. When a file is public it is served from cdn.useoper.com; when it is private, readers get a signed link that expires.

Who uses itOther services only — authoring-service-v2, authoring-image-worker, micro-learning-service-v2 (including its TTS worker and certificate issuer). No mobile or admin client calls it directly; both upload straight to the presigned S3 URL it hands out.
RuntimeFastAPI (synchronous SQLAlchemy sessions) · Deployment asset-manager, Service port 80 → 8000 (asset-manager-service/k8s/deployment.yaml:4, k8s/service.yaml:14)
DatabasePostgres logical DB tools, sole writer — one table, assets (k8s/es-db-asset-manager.yaml:26, ARCHITECTURE.md §3.4)
RedisIndex 3 is reserved for it in the platform map (k8s/configmap.yaml:7, ARCHITECTURE.md §3.5) but the application never connects: redis_url appears nowhere outside app/config.py:22 (app/api/health.py:53 docstring).
NATS streamsOwns none. Publishes one event on core NATS (no JetStream); authoring-service-v2's AUTHORING_CORE stream captures it (ARCHITECTURE.md §3.3).
External APIsS3/R2 bucket oper-assets in us-east-1 and the CloudFront distribution in front of it (k8s/secret.yaml:8, terraform/main.tf:27, terraform/variables.tf:22). Presigning is local HMAC — the bucket is never contacted to issue a ticket.
Entry points9 HTTP routes · 0 NATS consumers · 1 published subject · 0 background jobs

Path-prefix oddity

Its business routes live under /api/v1/* — the only service on the platform with an /api segment. The app mounts the /v1 routers under an extra /api prefix (app/main.py:96-97), and the platform papers over it with two different base-URL env vars: ASSET_MANAGER_BASE_URL already ends in /api while ASSET_MANAGER_BASE does not (global-configs/k8s/global-configmap.yaml:57-58). Callers using the first append /v1/…, callers using the second append api/v1/…; both land on the same routes. Health routes are mounted at the root, with no prefix at all.

2. Feature map

flowchart LR
  ASV2["authoring-service-v2"] --> TICKET["Get an upload ticket"]
  IW["authoring-image-worker"] --> TICKET
  ML["micro-learning-service-v2 (TTS, certificates, media)"] --> TICKET
  ASV2 --> LOOKUP["Look up an asset"]
  IW --> LOOKUP
  ML --> LOOKUP
  ASV2 --> COPY["Re-host under another tenant"]
  TICKET --> R1["POST /api/v1/create-upload-url"]
  TICKET --> R2["POST /api/v1/assets/{id}/mark-uploaded"]
  R2 --> EV["publish authoring.asset.upload.completed"]
  LOOKUP --> R3["GET /api/v1/assets/{id}"]
  LOOKUP --> R4["GET /api/v1/assets/{id}/url"]
  COPY --> R5["POST /api/v1/assets/copy"]
  ADMINOPS["Housekeeping (no in-repo caller)"] --> R6["GET /api/v1/assets"]
  ADMINOPS --> R7["DELETE /api/v1/assets/{id}"]

3. Features

Internal (other services)

Hand out an upload ticket live

internal

A service that has a file to store — a lesson image, a narration clip, a certificate PDF, a source document an author dragged in — asks for a ticket. It gets back a pre-signed URL valid for ten minutes and an asset id. The bytes then travel directly from whoever holds them to storage, never through this service, so a 40 MB PDF costs asset-manager nothing. The storage path is derived from the company, the visibility and the purpose, so ownership is readable from the key alone.

Entry points
POST /api/v1/create-upload-url
Touches
assets (row created as pending); S3 presign (local HMAC)
Related
ARCHITECTURE.md §3.2 edges H2, H8, H9 · docs/api_endpoints.md
Evidence
app/api/v1/upload.py:13 · callers: authoring-service-v2/app/core/http.py:294 · authoring-image-worker/app/asset_client.py:126 · micro-learning-service-v2/app/tts_worker/pipeline.py:962 · micro-learning-service-v2/app/services/certificate_service.py:102 · micro-learning-service-v2/app/routes/admin_audio_routes.py:136

Confirm the upload finished live

internal

Once the bytes are in the bucket the caller says so, and the asset flips from "expected" to "stored" with its real size and type. For source documents an author uploaded into the authoring tool, this is also the starting gun for the document-to-module pipeline: the confirmation fires an event that tells authoring-service-v2 the file is ready to be parsed and indexed. That event is sent after the response, best-effort — a NATS hiccup must never make an upload look failed when the file is safely stored.

Entry points
POST /api/v1/assets/{asset_id}/mark-uploaded
Touches
assets (status → uploaded); publishes authoring.asset.upload.completed for keys under /authoring-service-v2/
Related
Document-to-module flow, ARCHITECTURE.md §3.3
Evidence
app/api/v1/assets.py:20, publish queued at :52 · callers: authoring-image-worker/app/asset_client.py:203 · micro-learning-service-v2/app/tts_worker/pipeline.py:1010 · micro-learning-service-v2/app/services/certificate_service.py:117 · micro-learning-service-v2/app/routes/media_routes.py:49 · micro-learning-service-v2/app/routes/admin_audio_routes.py:168

Give me the link to this file live

internal

Turn an asset id into something a browser or app can open. Public files resolve to a plain CDN URL that never expires; private files resolve to a signed link with a deadline. Callers also ask for the raw record — size, type, status, owner — when they need to make a decision before showing the file.

Entry points
GET /api/v1/assets/{asset_id}/url · GET /api/v1/assets/{asset_id}
Touches
assets (read); S3 presign for private objects
Related
app/core/s3_client.py:68 builds the CDN URL by string formatting only
Evidence
app/api/v1/assets.py:99, :72 · callers: authoring-service-v2/app/core/http.py:311 and :326 · authoring-image-worker/app/asset_client.py:53 · micro-learning-service-v2/app/utils/asset_manager.py:19 · micro-learning-service-v2/app/tts_worker/pipeline.py:1027 · micro-learning-service-v2/app/services/certificate_service.py:127

Re-host a file under another company live

internal

When Oper's own team imports a customer's training module into the marketplace, the images must stop living in that customer's storage prefix — otherwise a marketplace card breaks the day the customer deletes their file, and its URL still carries their company id. This copies the object server-side into the destination company's prefix. Only internal callers may use it, only public objects can be copied, and the source must belong to the calling company.

Entry points
POST /api/v1/assets/copy (internal key only — Bearer callers get 403)
Touches
assets (new row); S3 server-side copy
Related
422 not_managed when the URL is not an asset-manager object — an externally hosted draft image is not an error
Evidence
app/api/v1/assets.py:177 · caller authoring-service-v2/app/core/http.py:361

List and delete assets suspect

internal

Housekeeping: page through a company's assets with filters, or remove one (soft by default, hard on request). No service in this repository calls either. The listing endpoint used to accept any company id in the query string and return that company's whole catalogue; it now pins non-internal callers to their own company.

Entry points
GET /api/v1/assets · DELETE /api/v1/assets/{asset_id}
Touches
assets
Related
Fixed IDOR: bug-hunt-reports/asset-manager-service.md #1 · open: malformed ids 500 (#4), negative paging (#5)
Evidence
app/api/v1/assets.py:141, :116 — repo-wide search for /api/v1/assets with no trailing segment, for list_assets and for delete_asset finds no caller outside this service and global-configs/management_scripts/upload_asset.py (a manual script that uses only create/mark/url)

Tell Kubernetes whether the pod can serve live

internal

Two probes. Liveness is dependency-free on purpose: a database blip must never get the container killed. Readiness reports on Postgres but deliberately always answers 200 — with only one replica there is no second pod to shift traffic to, so failing readiness would remove the upload path from the load balancer instead of returning real per-request errors.

Entry points
GET /live · GET /health
Touches
Postgres SELECT 1, bounded at 2 s
Related
The reasoning, including when to make readiness fail-closed, is in the handler docstring
Evidence
app/api/health.py:38, :53 · probes wired at k8s/deployment.yaml:84 (live) and :73 (health)

4. API reference

Auth on every business route is internal_auth_middleware: either X-Oper-Key + X-Tenant-Id (service-to-service) or a Bearer JWT validated against JWKS and bound to the X-Tenant-Id header (app/core/auth.py:60). All in-repo callers use the internal key.

MethodPathAuthFeatureCallersVerdict
POST/api/v1/create-upload-urlinternal key or BearerUpload ticketauthoring-service-v2 (http.py:294) · image-worker (asset_client.py:126) · micro-learning v2 ×3 (pipeline.py:962, certificate_service.py:102, admin_audio_routes.py:136)live
POST/api/v1/assets/{asset_id}/mark-uploadedinternal key or BearerConfirm uploadimage-worker (asset_client.py:203) · micro-learning v2 ×4 (pipeline.py:1010, certificate_service.py:117, media_routes.py:49, admin_audio_routes.py:168)live
GET/api/v1/assets/{asset_id}/urlinternal key or BearerResolve linkauthoring-service-v2 (http.py:311) · image-worker (asset_client.py:53) · micro-learning v2 ×4 (utils/asset_manager.py:19, pipeline.py:1027, certificate_service.py:127, admin_audio_routes.py:175)live
GET/api/v1/assets/{asset_id}internal key or BearerAsset recordauthoring-service-v2 (http.py:326)live
POST/api/v1/assets/copyinternal key onlyRe-hostauthoring-service-v2 (http.py:361)live
GET/api/v1/assetsinternal key or BearerHousekeepingnone foundsuspect
DELETE/api/v1/assets/{asset_id}internal key or BearerHousekeepingnone foundsuspect
GET/livenoneProbeskubelet — k8s/deployment.yaml:84live
GET/healthnoneProbeskubelet — k8s/deployment.yaml:73live

5. Async contracts

Consumes

SubjectStreamDurablePublished byFeatureVerdict
None. asset-manager-service subscribes to nothing; the scan found zero consumer registrations.

Publishes

SubjectConsumed byFeatureVerdict
authoring.asset.upload.completed
queued as a Starlette BackgroundTask at app/api/v1/assets.py:52; publisher app/core/nats_publisher.py:103
authoring-service-v2, registered at authoring-service-v2/app/consumers/register.py:65on_asset_upload_completed (app/tools/handlers/upload_handlers.py:4). Core-NATS publish; the AUTHORING_CORE stream captures authoring.asset.> and asv2 pull-consumes from the stream (ARCHITECTURE.md §3.3). Confirm upload → document-to-module pipeline live

Two properties worth knowing, both deliberate and documented in app/core/nats_publisher.py:1-17: the publish happens after the HTTP response is sent, so a cold or unhealthy NATS connection cannot be billed to the caller's latency; and it is fire-and-forget with no flush(), so a dropped event is logged and the authoring run is re-kicked rather than the upload being failed. It is also conditional — only object keys containing /authoring-service-v2/ publish anything (app/api/v1/assets.py:51), so image, audio and certificate uploads are silent by design.

Background jobs

JobScheduleWhat it doesVerdict
None. The only lifespan work is opening and draining the shared NATS connection (app/main.py:45, :51).

6. Data it owns

Postgres logical DB tools; asset-manager is the only writer (ARCHITECTURE.md §3.4).

TableWhat it holdsWritten by
assets One row per stored file: owning company and optional user, the storage key, public/private, size, MIME type, lifecycle status (pendinguploadeddeleted), a version counter and free-form tags. Indexed on tenant, object key, visibility, status and creation time. Upload ticket (creates pending), confirm upload (→ uploaded), copy (new row), delete (soft or hard)
migrations/versions/0001_create_assets.py:27 · indexes at migrations/versions/0002_assets_perf_indexes.py

No cross-service writes: nothing else writes tools, and asset-manager writes no other service's database. The real state it guards is outside Postgres — the oper-assets bucket, whose public-read policy is scoped to the CloudFront distribution's ARN (terraform/main.tf:121-131) and whose CORS rules exist specifically so browsers can PUT directly to a presigned URL (terraform/main.tf:55-62).

7. Dependencies

flowchart LR
  ASV2["authoring-service-v2"] --> AM["asset-manager"]
  IW["authoring-image-worker"] --> AM
  ML["micro-learning-service-v2 + TTS worker"] --> AM
  K8S["kubelet probes"] --> AM
  AM --> PG[("Postgres tools")]
  AM --> S3[("S3 oper-assets")]
  AM --> CDN["CloudFront cdn.useoper.com"]
  AM --> NATS["NATS core"]
  NATS --> ASV2B["authoring-service-v2 asset consumer"]

ARCHITECTURE.md §3.2 records three inbound HTTP edges (H2 authoring-v2, H8 image-worker, H9 tts-worker-v2). The TTS worker's code lives inside micro-learning-service-v2/app/tts_worker/, and the same service also calls asset-manager from its certificate issuer, media routes and admin audio routes — so the micro-learning image is a caller in its own right, not only through the TTS worker.

8. Dead-code verdicts

Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.

Entry pointKindVerdictEvidence
GET /api/v1/assets HTTP route suspect app/api/v1/assets.py:141. Searched the repo for the path, for list_assets, and for asset-manager base-URL usages in every client module — no caller. Not dead: the route accepts Bearer JWTs as well as the internal key (app/core/auth.py:84-96), so an out-of-repo back-office client could reach it. Resolving it needs either the admin web repo or access-log sampling for this path.
DELETE /api/v1/assets/{asset_id} HTTP route suspect app/api/v1/assets.py:116. Same search, same result: nothing in the repo deletes assets, and no cleanup CronJob exists in asset-manager-service/k8s/. Same Bearer-reachability caveat as above. Note that nothing else deletes them either, so the bucket has no retention path today.
Redis (REDIS_URL, index 3) Config / infrastructure dead Configured: k8s/configmap.yaml:7 sets index 3 and app/config.py:22 reads it. Never used: redis_url / self.redis appear nowhere else in app/ — confirmed by the readiness handler, which dropped its Redis check for exactly this reason (app/api/health.py:53 docstring). The index is still reserved in the platform map (ARCHITECTURE.md §3.5), so removing the key is a platform-map edit, not a code change.

Everything else on this page has a named caller. There are no dead NATS consumers here because there are no consumers at all, and the single published subject has a verified subscriber.

9. Sources