feat(platform-service): restrict blob SAS/list/info to user scope
This commit is contained in:
parent
63ab1631e1
commit
1011fd85f8
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user