From 1011fd85f896cc83e646bec19b7237cb26e67dd3 Mon Sep 17 00:00:00 2001 From: Saravana Dhandapani Date: Sun, 15 Feb 2026 03:31:27 -0800 Subject: [PATCH] feat(platform-service): restrict blob SAS/list/info to user scope --- .../MOBILE_WORKSTREAM_REMAINING.md | 1 + .../src/modules/blob/routes.ts | 78 +++++++++++++++++-- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md b/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md index fbd4e53a..6c232d11 100644 --- a/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md +++ b/docs/workstreams/MOBILE_WORKSTREAM_REMAINING.md @@ -89,6 +89,7 @@ Voice capture pipeline - [ ] **P0** — Integrate Azure Speech SDK for real-time STT on iOS (scaffolded) — https://github.com/saravanakumardb1/learning_multimodal_memory_agents/commit/8834322 - [ ] **P0** — Integrate Azure Speech SDK for real-time STT on Android - [ ] **P1** — Upload raw audio to Azure Blob Storage after capture +- [x] **P1** — Backend: user-scoped SAS URL generation for Blob uploads (`platform-service` `POST /api/blob/sas`) Text capture diff --git a/services/platform-service/src/modules/blob/routes.ts b/services/platform-service/src/modules/blob/routes.ts index df376e10..333b3cf6 100644 --- a/services/platform-service/src/modules/blob/routes.ts +++ b/services/platform-service/src/modules/blob/routes.ts @@ -19,6 +19,13 @@ import { } from '../../lib/blob.js'; import { GenerateSasSchema, ListBlobsSchema, DeleteBlobSchema, type BlobInfo } from './types.js'; +const USER_ALLOWED_CONTAINERS = new Set([ + BLOB_CONTAINERS.audio, + BLOB_CONTAINERS.transcripts, + BLOB_CONTAINERS.attachments, + BLOB_CONTAINERS.avatars, +]); + async function requireAuth(req: { headers: Record }) { const authHeader = req.headers.authorization; if (!authHeader || typeof authHeader !== 'string') { @@ -28,6 +35,27 @@ async function requireAuth(req: { headers: Record { @@ -38,9 +66,21 @@ export async function blobRoutes(app: FastifyInstance) { } const { container, blobName, permissions, expiresInMinutes } = parsed.data; - // Only admins can generate write/delete SAS tokens - if (permissions !== 'r' && auth.role !== 'admin') { - throw new UnauthorizedError('Only admins can generate write SAS tokens'); + const admin = isAdminRole(auth.role); + if (!admin) { + if (!USER_ALLOWED_CONTAINERS.has(container)) { + throw new UnauthorizedError(`Not allowed to access container: ${container}`); + } + + const prefix = userPrefix({ productId: auth.productId, sub: auth.sub }); + if (!blobName.startsWith(prefix)) { + throw new UnauthorizedError('Not allowed to access blobs outside your user scope'); + } + + // Never grant delete permissions to non-admins. + if (permissionsInclude(permissions, 'd')) { + throw new UnauthorizedError('Only admins can generate delete SAS tokens'); + } } const sasUrl = generateSasUrl(container, blobName, permissions, expiresInMinutes); @@ -56,17 +96,28 @@ export async function blobRoutes(app: FastifyInstance) { // List blobs in a container app.get('/blob/list', async req => { - await requireAuth(req); + const auth = await requireAuth(req); const parsed = ListBlobsSchema.safeParse(req.query); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const { container, prefix, limit } = parsed.data; + const admin = isAdminRole(auth.role); + const { container, limit } = parsed.data; + + if (!admin && !USER_ALLOWED_CONTAINERS.has(container)) { + throw new UnauthorizedError(`Not allowed to access container: ${container}`); + } + + const requestedPrefix = normalizePrefix(parsed.data.prefix); + const effectivePrefix = admin + ? requestedPrefix + : `${userPrefix({ productId: auth.productId, sub: auth.sub })}${requestedPrefix ?? ''}`; + const containerClient = await getContainerClient(container); const blobs: BlobInfo[] = []; let count = 0; - for await (const blob of containerClient.listBlobsFlat({ prefix: prefix || undefined })) { + for await (const blob of containerClient.listBlobsFlat({ prefix: effectivePrefix || undefined })) { if (count >= limit) break; blobs.push({ name: blob.name, @@ -80,7 +131,7 @@ export async function blobRoutes(app: FastifyInstance) { count++; } - return { blobs, count: blobs.length, container, prefix: prefix || null }; + return { blobs, count: blobs.length, container, prefix: effectivePrefix || null }; }); // Delete a blob @@ -109,13 +160,24 @@ export async function blobRoutes(app: FastifyInstance) { // Get blob metadata/info app.get('/blob/info/:container/:blobName', async req => { - await requireAuth(req); + const auth = await requireAuth(req); const { container, blobName } = req.params as { container: string; blobName: string }; if (!Object.values(BLOB_CONTAINERS).includes(container as never)) { throw new BadRequestError(`Invalid container: ${container}`); } + const admin = isAdminRole(auth.role); + if (!admin) { + if (!USER_ALLOWED_CONTAINERS.has(container)) { + throw new UnauthorizedError(`Not allowed to access container: ${container}`); + } + const prefix = userPrefix({ productId: auth.productId, sub: auth.sub }); + if (!blobName.startsWith(prefix)) { + throw new UnauthorizedError('Not allowed to access blobs outside your user scope'); + } + } + const containerClient = await getContainerClient(container); const blobClient = containerClient.getBlobClient(blobName);