feat(platform-service): restrict blob SAS/list/info to user scope

This commit is contained in:
Saravana Dhandapani 2026-02-15 03:31:27 -08:00
parent 63ab1631e1
commit 1011fd85f8
2 changed files with 71 additions and 8 deletions

View File

@ -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

View File

@ -19,6 +19,13 @@ import {
} from '../../lib/blob.js';
import { GenerateSasSchema, ListBlobsSchema, DeleteBlobSchema, type BlobInfo } from './types.js';
const USER_ALLOWED_CONTAINERS = new Set<string>([
BLOB_CONTAINERS.audio,
BLOB_CONTAINERS.transcripts,
BLOB_CONTAINERS.attachments,
BLOB_CONTAINERS.avatars,
]);
async function requireAuth(req: { headers: Record<string, string | string[] | undefined> }) {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== 'string') {
@ -28,6 +35,27 @@ async function requireAuth(req: { headers: Record<string, string | string[] | un
return verifyToken(token);
}
function isAdminRole(role: unknown): boolean {
return role === 'admin' || role === 'super_admin';
}
function userPrefix(auth: { productId?: string; sub: string }): string {
if (!auth.productId) return `${auth.sub}/`;
return `${auth.productId}/${auth.sub}/`;
}
function permissionsInclude(permissions: string, chars: string): boolean {
for (const ch of chars) {
if (permissions.includes(ch)) return true;
}
return false;
}
function normalizePrefix(input: string | undefined): string | undefined {
if (!input) return undefined;
return input.replace(/^\/+/, '');
}
export async function blobRoutes(app: FastifyInstance) {
// Generate SAS URL for direct upload/download
app.post('/blob/sas', async req => {
@ -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);