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 it | Other 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. |
| Runtime | FastAPI (synchronous SQLAlchemy sessions) · Deployment asset-manager, Service port 80 → 8000 (asset-manager-service/k8s/deployment.yaml:4, k8s/service.yaml:14) |
| Database | Postgres logical DB tools, sole writer — one table, assets (k8s/es-db-asset-manager.yaml:26, ARCHITECTURE.md §3.4) |
| Redis | Index 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 streams | Owns none. Publishes one event on core NATS (no JetStream); authoring-service-v2's AUTHORING_CORE stream captures it (ARCHITECTURE.md §3.3). |
| External APIs | S3/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 points | 9 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
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 aspending); 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
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); publishesauthoring.asset.upload.completedfor 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
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:68builds 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
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_managedwhen 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
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/assetswith no trailing segment, forlist_assetsand fordelete_assetfinds no caller outside this service andglobal-configs/management_scripts/upload_asset.py(a manual script that uses only create/mark/url)
Tell Kubernetes whether the pod can serve live
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.
| Method | Path | Auth | Feature | Callers | Verdict |
|---|---|---|---|---|---|
| POST | /api/v1/create-upload-url | internal key or Bearer | Upload ticket | authoring-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-uploaded | internal key or Bearer | Confirm upload | image-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}/url | internal key or Bearer | Resolve link | authoring-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 Bearer | Asset record | authoring-service-v2 (http.py:326) | live |
| POST | /api/v1/assets/copy | internal key only | Re-host | authoring-service-v2 (http.py:361) | live |
| GET | /api/v1/assets | internal key or Bearer | Housekeeping | none found | suspect |
| DELETE | /api/v1/assets/{asset_id} | internal key or Bearer | Housekeeping | none found | suspect |
| GET | /live | none | Probes | kubelet — k8s/deployment.yaml:84 | live |
| GET | /health | none | Probes | kubelet — k8s/deployment.yaml:73 | live |
5. Async contracts
Consumes
| Subject | Stream | Durable | Published by | Feature | Verdict |
|---|---|---|---|---|---|
| None. asset-manager-service subscribes to nothing; the scan found zero consumer registrations. | |||||
Publishes
| Subject | Consumed by | Feature | Verdict |
|---|---|---|---|
authoring.asset.upload.completedqueued 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:65 → on_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
| Job | Schedule | What it does | Verdict |
|---|---|---|---|
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).
| Table | What it holds | Written by |
|---|---|---|
assets |
One row per stored file: owning company and optional user, the storage key, public/private, size, MIME type, lifecycle status (pending → uploaded → deleted), 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 point | Kind | Verdict | Evidence |
|---|---|---|---|
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
asset-manager-service/docs/api_endpoints.md— request/response shapes and constraints per endpoint.asset-manager-service/spec/spec-driven.md— the original design for object-key layout, visibility and lifecycle.bug-hunt-reports/asset-manager-service.md— six findings; #1 (list IDOR), #2/#3 (auth fail-open) fixed, #4–#6 open.ARCHITECTURE.md§3.2 (edges H2, H8, H9), §3.3 (AUTHORING_CORE), §3.4 (thetoolsDB), §3.5 (Redis map), §3.6 (external egress).- Code read directly:
app/main.py,app/api/v1/assets.py,app/api/v1/upload.py,app/api/health.py,app/core/auth.py,app/core/nats_publisher.py,app/core/s3_client.py,app/config.py,migrations/versions/,terraform/main.tf,terraform/variables.tf,k8s/. - Caller verification:
authoring-service-v2/app/core/http.py,authoring-service-v2/app/consumers/register.py,authoring-image-worker/app/asset_client.py,micro-learning-service-v2/app/tts_worker/pipeline.py,.../app/services/certificate_service.py,.../app/routes/media_routes.py,.../app/routes/admin_audio_routes.py,.../app/utils/asset_manager.py,global-configs/k8s/global-configmap.yaml.