diff --git a/services/platform-service/src/lib/auto-register.ts b/services/platform-service/src/lib/auto-register.ts index 370c1bdb..cfff520b 100644 --- a/services/platform-service/src/lib/auto-register.ts +++ b/services/platform-service/src/lib/auto-register.ts @@ -105,6 +105,7 @@ export async function autoRegisterProduct( deviceLimits: { free: 1, pro: 3, enterprise: 10 }, websiteUrl: '', status: 'active', + ownerId: userId, createdAt: now, updatedAt: now, }; diff --git a/services/platform-service/src/lib/request-context.test.ts b/services/platform-service/src/lib/request-context.test.ts new file mode 100644 index 00000000..fe27e01e --- /dev/null +++ b/services/platform-service/src/lib/request-context.test.ts @@ -0,0 +1,54 @@ +/** + * requireProductAccess — flag-gated, owner-scoped tenant authorization (§tenancy). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { FastifyRequest } from 'fastify'; + +let authPayload: { sub: string; role?: string } = { sub: 'owner_1', role: 'user' }; + +vi.mock('./auth.js', () => ({ extractAuth: vi.fn(async () => authPayload) })); +vi.mock('./auto-register.js', () => ({ autoRegisterProduct: vi.fn() })); +vi.mock('../modules/products/cache.js', () => ({ + isValidProduct: () => true, + getProduct: (id: string) => ({ id, productId: id, status: 'active', ownerId: 'owner_1' }), +})); + +import { requireProductAccess, isTenantEnforcementEnabled } from './request-context.js'; +import { ForbiddenError } from './errors.js'; + +const reqFor = (productId: string): FastifyRequest => + ({ jwtPayload: { sub: authPayload.sub, productId }, headers: {} }) as unknown as FastifyRequest; + +afterEach(() => { + delete process.env.FLEET_TENANT_ENFORCEMENT; + authPayload = { sub: 'owner_1', role: 'user' }; +}); + +describe('requireProductAccess', () => { + it('enforcement OFF (default): resolves without an access check', async () => { + authPayload = { sub: 'someone_else', role: 'user' }; + await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1'); + expect(isTenantEnforcementEnabled()).toBe(false); + }); + + it('enforcement ON: the owner is allowed', async () => { + process.env.FLEET_TENANT_ENFORCEMENT = '1'; + authPayload = { sub: 'owner_1', role: 'user' }; + await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1'); + }); + + it('enforcement ON: a non-owner, non-admin user is rejected (403)', async () => { + process.env.FLEET_TENANT_ENFORCEMENT = '1'; + authPayload = { sub: 'intruder', role: 'user' }; + await expect(requireProductAccess(reqFor('p1'))).rejects.toBeInstanceOf(ForbiddenError); + }); + + it('enforcement ON: admins and super_admins are always allowed', async () => { + process.env.FLEET_TENANT_ENFORCEMENT = '1'; + authPayload = { sub: 'intruder', role: 'admin' }; + await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1'); + authPayload = { sub: 'intruder', role: 'super_admin' }; + await expect(requireProductAccess(reqFor('p1'))).resolves.toBe('p1'); + }); +}); diff --git a/services/platform-service/src/lib/request-context.ts b/services/platform-service/src/lib/request-context.ts index ff39d7f6..a9e17ab1 100644 --- a/services/platform-service/src/lib/request-context.ts +++ b/services/platform-service/src/lib/request-context.ts @@ -10,10 +10,11 @@ */ import type { FastifyRequest } from 'fastify'; -import { BadRequestError } from './errors.js'; +import { BadRequestError, ForbiddenError } from './errors.js'; import { isValidProduct, getProduct } from '../modules/products/cache.js'; import type { ProductDoc } from '../modules/products/types.js'; import { autoRegisterProduct } from './auto-register.js'; +import { extractAuth } from './auth.js'; /** JWT payload shape attached to req by the onRequest hook (see Commit 3). */ export interface JwtPayload { @@ -136,3 +137,42 @@ export function getRequestProductConfig(req: FastifyRequest): ProductDoc { const id = getRequestProductId(req); return getProduct(id)!; } + +/** + * `FLEET_TENANT_ENFORCEMENT` env gate — default OFF. When OFF, tenant scoping is + * advisory only (the dashboard shows you your projects, but the API does not + * reject cross-tenant access) — byte-for-byte the current behavior. When ON, the + * API enforces that a caller may only act on products they own (admins exempt). + */ +export function isTenantEnforcementEnabled(): boolean { + const v = (process.env.FLEET_TENANT_ENFORCEMENT ?? '').trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'on' || v === 'yes'; +} + +/** + * Resolve the request's productId AND authorize the caller for it (multi-tenant + * guard, §tenancy). Resolution + status gating is unchanged (`getRequestProductId`); + * the access check is additive and flag-gated: + * + * - enforcement OFF ⇒ returns the resolved id (no behavior change). + * - admins / super_admins ⇒ always allowed (operators see everything). + * - otherwise ⇒ allowed only when the product is owned by the caller, OR is + * owner-less/legacy (grace for products created before ownership tracking; + * migrate them to lock down fully). A product owned by SOMEONE ELSE ⇒ 403. + * + * Async because it needs the verified auth identity. Use this on tenant-scoped + * surfaces (e.g. the fleet control plane) in place of `getRequestProductId`. + */ +export async function requireProductAccess(req: FastifyRequest): Promise { + const id = getRequestProductId(req); + if (!isTenantEnforcementEnabled()) return id; + + const auth = await extractAuth(req); + if (auth.role === 'admin' || auth.role === 'super_admin') return id; + + const product = getProduct(id); + if (product?.ownerId && product.ownerId !== auth.sub) { + throw new ForbiddenError(`Not authorized for product '${id}'`); + } + return id; +} diff --git a/services/platform-service/src/modules/fleet/artifacts.test.ts b/services/platform-service/src/modules/fleet/artifacts.test.ts index d15daf10..49f4fb99 100644 --- a/services/platform-service/src/modules/fleet/artifacts.test.ts +++ b/services/platform-service/src/modules/fleet/artifacts.test.ts @@ -25,6 +25,7 @@ vi.mock('../../lib/auth.js', () => ({ })); vi.mock('../../lib/request-context.js', () => ({ getRequestProductId: () => 'lysnrai', + requireProductAccess: async () => 'lysnrai', })); const PID = 'lysnrai'; diff --git a/services/platform-service/src/modules/fleet/enrollment.test.ts b/services/platform-service/src/modules/fleet/enrollment.test.ts index 0d13a7aa..c84b1fce 100644 --- a/services/platform-service/src/modules/fleet/enrollment.test.ts +++ b/services/platform-service/src/modules/fleet/enrollment.test.ts @@ -21,6 +21,7 @@ vi.mock('../../lib/auth.js', () => ({ })); vi.mock('../../lib/request-context.js', () => ({ getRequestProductId: () => 'lysnrai', + requireProductAccess: async () => 'lysnrai', })); const PID = 'lysnrai'; diff --git a/services/platform-service/src/modules/fleet/routes.test.ts b/services/platform-service/src/modules/fleet/routes.test.ts index a2af5316..420764d6 100644 --- a/services/platform-service/src/modules/fleet/routes.test.ts +++ b/services/platform-service/src/modules/fleet/routes.test.ts @@ -13,6 +13,7 @@ vi.mock('../../lib/auth.js', () => ({ })); vi.mock('../../lib/request-context.js', () => ({ getRequestProductId: () => 'lysnrai', + requireProductAccess: async () => 'lysnrai', })); async function buildApp(): Promise { diff --git a/services/platform-service/src/modules/fleet/routes.ts b/services/platform-service/src/modules/fleet/routes.ts index 02879ca5..427f3e21 100644 --- a/services/platform-service/src/modules/fleet/routes.ts +++ b/services/platform-service/src/modules/fleet/routes.ts @@ -25,7 +25,7 @@ */ import type { FastifyInstance } from 'fastify'; -import { getRequestProductId } from '../../lib/request-context.js'; +import { requireProductAccess } from '../../lib/request-context.js'; import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../../lib/errors.js'; import { extractAuth } from '../../lib/auth.js'; import * as repo from './repository.js'; @@ -64,8 +64,8 @@ function badRequest(issues: { message: string }[]): never { * matches it. Prevents a caller authenticated for product A from reading or * mutating another product's fleet budget. Returns the caller's productId. */ -function requireOwnProduct(req: import('fastify').FastifyRequest): string { - const pid = getRequestProductId(req); +async function requireOwnProduct(req: import('fastify').FastifyRequest): Promise { + const pid = await requireProductAccess(req); const { productId } = req.params as { productId?: string }; if (productId && productId !== pid) { throw new ForbiddenError("Cannot access another product's fleet budget"); @@ -88,7 +88,7 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = SubmitJobSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); - const pid = parsed.data.productId || getRequestProductId(req); + const pid = parsed.data.productId || (await requireProductAccess(req)); const result = await coordinator.submitJob(pid, parsed.data); reply.code(result.outcome === 'created' ? 201 : 200); return { outcome: result.outcome, job: result.job }; @@ -100,7 +100,7 @@ export async function fleetRoutes(app: FastifyInstance) { const parsed = ListJobsQuerySchema.safeParse(req.query); if (!parsed.success) badRequest(parsed.error.issues); const q = parsed.data; - const pid = q.productId || getRequestProductId(req); + const pid = q.productId || (await requireProductAccess(req)); const jobs = await repo.listJobs({ productId: pid, stage: q.stage, @@ -115,7 +115,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.get('/fleet/jobs/:id', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const job = await repo.getJob(id, pid); if (!job) throw new NotFoundError('Job not found'); return job; @@ -125,7 +125,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.patch('/fleet/jobs/:id', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const parsed = PatchJobSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const res = await coordinator.patchJobFenced(id, pid, parsed.data); @@ -148,7 +148,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/actions/:action', async req => { await extractAuth(req); const { id, action } = req.params as { id: string; action: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); if (!(coordinator.OPERATOR_ACTIONS as readonly string[]).includes(action)) { throw new BadRequestError( `unknown operator action '${action}' (expected: ${coordinator.OPERATOR_ACTIONS.join(', ')})` @@ -170,7 +170,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.patch('/fleet/jobs/:id/draft', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const parsed = UpdateDraftSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const res = await coordinator.updateDraft(id, pid, parsed.data); @@ -185,7 +185,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/submit', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const res = await coordinator.submitDraft(id, pid); if (!res.ok) { if (res.reason === 'not_found') throw new NotFoundError('Job not found'); @@ -199,7 +199,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/review/request', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const body = (req.body ?? {}) as { requiredApprovals?: number; reviewers?: string[] }; const res = await coordinator.requestReview(id, pid, { requiredApprovals: body.requiredApprovals, @@ -222,7 +222,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/review', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const parsed = SubmitReviewSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const res = await coordinator.submitReview(id, pid, parsed.data); @@ -244,7 +244,7 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = ClaimSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); - const pid = parsed.data.productId || getRequestProductId(req); + const pid = parsed.data.productId || (await requireProductAccess(req)); // §12: when enforcement is ON, the token must cover the productId + requested // capabilities + factoryId; the claim is then CONSTRAINED to the verified scope // (a factory cannot claim outside it). When OFF, behaves exactly as before. @@ -267,7 +267,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/lease/renew', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const parsed = RenewLeaseSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const res = await coordinator.renewLease( @@ -288,7 +288,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/lease/release', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const parsed = ReleaseLeaseSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const res = await coordinator.releaseLease(id, pid, parsed.data.leaseEpoch, parsed.data.stage, { @@ -313,7 +313,7 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = HeartbeatSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); - const pidCandidate = parsed.data.productId || getRequestProductId(req); + const pidCandidate = parsed.data.productId || (await requireProductAccess(req)); // §12: gated token check (default OFF). When ON the token must cover this // product + factory; the heartbeat is then bound to the verified scope. const scope = await enrollment.enforceFactoryToken(req, { @@ -345,7 +345,7 @@ export async function fleetRoutes(app: FastifyInstance) { // ── List factories for the product (powers the fleet map + engine picker) ── app.get('/fleet/factories', async req => { await extractAuth(req); - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const factories = await repo.listFactories(pid); return { factories }; }); @@ -437,7 +437,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.get('/fleet/jobs/:id/explain', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const explain = await coordinator.explainJob(id, pid); if (!explain) throw new NotFoundError('Job not found'); return explain; @@ -446,7 +446,7 @@ export async function fleetRoutes(app: FastifyInstance) { // ── Fleet metrics + alerting (§17) ── app.get('/fleet/metrics', async req => { await extractAuth(req); - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const metrics = await coordinator.fleetMetrics(pid); // Attach process-wide recovery/GC telemetry (the reaper runs across products, // so these counters are global — operator visibility into recovery activity). @@ -461,7 +461,7 @@ export async function fleetRoutes(app: FastifyInstance) { // docs/GIGAFACTORY/FLEET_DISPATCH_REDESIGN.md §8/§12. app.get('/fleet/queue-state', async req => { await extractAuth(req); - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); return { productId: pid, version: await repo.getQueueVersion(pid) }; }); @@ -473,7 +473,7 @@ export async function fleetRoutes(app: FastifyInstance) { if (!parsed.success) badRequest(parsed.error.issues); // Review fix: the request/auth productId is authoritative — a spoofed // body.productId must NOT override it (prevents writing into another product). - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const bytes = Buffer.from(parsed.data.contentBase64, 'base64'); if (bytes.length === 0) badRequest([{ message: 'contentBase64 decoded to empty bytes' }]); const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({ @@ -494,7 +494,7 @@ export async function fleetRoutes(app: FastifyInstance) { const { id: jobId } = req.params as { id: string }; // Review fix: scope to the request productId so a caller only sees its own // product's artifact pointers for this job. - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const artifacts = await repo.listArtifactsByJob(jobId, pid); return { artifacts }; }); @@ -503,7 +503,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.get('/fleet/artifacts/:artifactId', async req => { await extractAuth(req); const { artifactId } = req.params as { artifactId: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const found = await artifactsBlob.getArtifactDownload(artifactId, pid); if (!found) throw new NotFoundError('Artifact not found'); return found; @@ -513,7 +513,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.delete('/fleet/artifacts/:artifactId', async req => { await extractAuth(req); const { artifactId } = req.params as { artifactId: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const deleted = await artifactsBlob.deleteArtifact(artifactId, pid); if (!deleted) throw new NotFoundError('Artifact not found'); return { deleted: true }; @@ -526,7 +526,7 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = EnrollFactorySchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); - const pid = parsed.data.productId || getRequestProductId(req); + const pid = parsed.data.productId || (await requireProductAccess(req)); const result = await enrollment.enrollFactory({ productId: pid, factoryId: parsed.data.factoryId, @@ -548,7 +548,7 @@ export async function fleetRoutes(app: FastifyInstance) { const { id: factoryId } = req.params as { id: string }; const parsed = RotateTokenSchema.safeParse(req.body ?? {}); if (!parsed.success) badRequest(parsed.error.issues); - const pid = parsed.data.productId || getRequestProductId(req); + const pid = parsed.data.productId || (await requireProductAccess(req)); const factory = await repo.getFactory(factoryId, pid); if (!factory) throw new NotFoundError('Factory not found'); const issued = await enrollment.rotateToken({ @@ -565,7 +565,7 @@ export async function fleetRoutes(app: FastifyInstance) { const { id: factoryId } = req.params as { id: string }; const parsed = RevokeTokenSchema.safeParse(req.body ?? {}); if (!parsed.success) badRequest(parsed.error.issues); - const pid = parsed.data.productId || getRequestProductId(req); + const pid = parsed.data.productId || (await requireProductAccess(req)); const revoked = await enrollment.revokeToken({ productId: pid, factoryId, @@ -577,7 +577,7 @@ export async function fleetRoutes(app: FastifyInstance) { // ── Phase 3 Budgets: GET/PUT per-product budget ── app.get('/fleet/budgets/:productId', async req => { await extractAuth(req); - const productId = requireOwnProduct(req); + const productId = await requireOwnProduct(req); const budget = await coordinator.getBudget(productId); if (!budget) throw new NotFoundError('No budget configured for this product'); return budget; @@ -586,7 +586,7 @@ export async function fleetRoutes(app: FastifyInstance) { // ── Cost burndown — spend-over-time vs ceiling (§14) ── app.get('/fleet/budgets/:productId/burndown', async req => { await extractAuth(req); - const productId = requireOwnProduct(req); + const productId = await requireOwnProduct(req); const { days } = req.query as { days?: string }; const parsedDays = days ? Number.parseInt(days, 10) : undefined; return coordinator.costBurndown( @@ -597,7 +597,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.put('/fleet/budgets/:productId', async (req, reply) => { await extractAuth(req); - const productId = requireOwnProduct(req); + const productId = await requireOwnProduct(req); const parsed = UpsertBudgetSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const budget = await coordinator.upsertBudget( @@ -612,7 +612,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/budgets/:productId/pause', async req => { await extractAuth(req); - const productId = requireOwnProduct(req); + const productId = await requireOwnProduct(req); const budget = await coordinator.pauseBudget(productId); if (!budget) throw new NotFoundError('No budget configured for this product'); return budget; @@ -620,7 +620,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/budgets/:productId/resume', async req => { await extractAuth(req); - const productId = requireOwnProduct(req); + const productId = await requireOwnProduct(req); const budget = await coordinator.resumeBudget(productId); if (!budget) throw new NotFoundError('No budget configured for this product'); return budget; @@ -630,7 +630,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.post('/fleet/jobs/:id/children', async (req, reply) => { await extractAuth(req); const { id: parentId } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const parsed = SubmitChildrenSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); const parent = await repo.getJob(parentId, pid); @@ -644,7 +644,7 @@ export async function fleetRoutes(app: FastifyInstance) { app.get('/fleet/jobs/:id/dag', async req => { await extractAuth(req); const { id } = req.params as { id: string }; - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const dag = await coordinator.getDagSubtree(id, pid); if (!dag) throw new NotFoundError('Job not found'); return { dag }; @@ -655,7 +655,7 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = IngestItemSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); const result = await trackerBridge.ingestItemAsJob(pid, parsed.data.itemId, { priority: parsed.data.priority, }); @@ -668,7 +668,7 @@ export async function fleetRoutes(app: FastifyInstance) { await extractAuth(req); const parsed = EchoJobSchema.safeParse(req.body); if (!parsed.success) badRequest(parsed.error.issues); - const pid = getRequestProductId(req); + const pid = await requireProductAccess(req); return trackerBridge.echoJobToItem(pid, parsed.data.jobId, req.log); }); } diff --git a/services/platform-service/src/modules/fleet/tracker-bridge.test.ts b/services/platform-service/src/modules/fleet/tracker-bridge.test.ts index 1483e8ca..ec083a38 100644 --- a/services/platform-service/src/modules/fleet/tracker-bridge.test.ts +++ b/services/platform-service/src/modules/fleet/tracker-bridge.test.ts @@ -22,6 +22,7 @@ vi.mock('../../lib/auth.js', () => ({ })); vi.mock('../../lib/request-context.js', () => ({ getRequestProductId: () => 'lysnrai', + requireProductAccess: async () => 'lysnrai', })); const PID = 'lysnrai'; diff --git a/services/platform-service/src/modules/products/cache.test.ts b/services/platform-service/src/modules/products/cache.test.ts index b19a70e7..4acd5a22 100644 --- a/services/platform-service/src/modules/products/cache.test.ts +++ b/services/platform-service/src/modules/products/cache.test.ts @@ -14,6 +14,7 @@ import { getProduct, isValidProduct, getAllProducts, + productsForUser, cacheSize, } from './cache.js'; @@ -119,6 +120,37 @@ describe('product cache', () => { }); }); + describe('productsForUser (owner-scoped "my projects")', () => { + const owned = [ + { ...products[0], ownerId: 'user_a' }, + { ...products[1], ownerId: 'user_b' }, + ]; + + it('a normal user sees only the products they own', async () => { + mockGetAll.mockResolvedValue(owned); + await loadProductCache(); + const mine = productsForUser('user_a', false); + expect(mine.map(p => p.id)).toEqual(['lysnrai']); + }); + + it('owner-less / other-owner products are hidden from a normal user', async () => { + mockGetAll.mockResolvedValue(owned); + await loadProductCache(); + // invttrdg (the fallback) has no ownerId ⇒ never shown to a non-admin. + expect(productsForUser('user_a', false).map(p => p.id)).not.toContain('invttrdg'); + expect(productsForUser('nobody', false)).toEqual([]); + }); + + it('an admin sees every product regardless of owner', async () => { + mockGetAll.mockResolvedValue(owned); + await loadProductCache(); + const all = productsForUser('anyone', true); + expect(all.map(p => p.id)).toEqual( + expect.arrayContaining(['lysnrai', 'mindlyst', 'invttrdg']) + ); + }); + }); + describe('cacheSize', () => { it('returns 0 before loading', async () => { mockGetAll.mockResolvedValue([]); diff --git a/services/platform-service/src/modules/products/cache.ts b/services/platform-service/src/modules/products/cache.ts index 1f80ccce..e3d2b041 100644 --- a/services/platform-service/src/modules/products/cache.ts +++ b/services/platform-service/src/modules/products/cache.ts @@ -58,6 +58,18 @@ export function getAllProducts(): ProductDoc[] { ]; } +/** + * Products visible to a given user ("my projects"). Admins/super_admins see + * every product; everyone else sees only the products they own (`ownerId`). + * Owner-less legacy products are NOT shown to non-admins (they belong to no one + * in particular), which keeps a generic, multi-tenant deployment isolated. + */ +export function productsForUser(userId: string, isAdmin: boolean): ProductDoc[] { + const all = getAllProducts(); + if (isAdmin) return all; + return all.filter(p => p.ownerId === userId); +} + /** Current cache size (for tests/health). */ export function cacheSize(): number { return productCache.size; diff --git a/services/platform-service/src/modules/products/routes.ts b/services/platform-service/src/modules/products/routes.ts index 1654d0bb..2b540fa5 100644 --- a/services/platform-service/src/modules/products/routes.ts +++ b/services/platform-service/src/modules/products/routes.ts @@ -9,8 +9,9 @@ import type { FastifyInstance } from 'fastify'; import { BadRequestError, ConflictError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; import * as repo from './repository.js'; -import { loadProductCache, getAllProducts, getProduct } from './cache.js'; +import { loadProductCache, getAllProducts, getProduct, productsForUser } from './cache.js'; import { CreateProductSchema, UpdateProductSchema, @@ -25,6 +26,15 @@ export async function productRoutes(app: FastifyInstance) { return { products: getAllProducts() }; }); + // List the authenticated caller's products ("my projects"). Owner-scoped: + // a normal user sees the products they own; admins/super_admins see them all. + // This is the per-user, multi-tenant view the dashboard switcher consumes. + app.get('/products/mine', async req => { + const auth = await extractAuth(req); + const isAdmin = auth.role === 'admin' || auth.role === 'super_admin'; + return { products: productsForUser(auth.sub, isAdmin) }; + }); + // Get single product (served from cache) app.get('/products/:id', async req => { const { id } = req.params as { id: string }; @@ -35,6 +45,7 @@ export async function productRoutes(app: FastifyInstance) { // Create product (admin-only) app.post('/products', async (req, reply) => { + const auth = await extractAuth(req); const parsed = CreateProductSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); @@ -49,6 +60,9 @@ export async function productRoutes(app: FastifyInstance) { const doc: ProductDoc = { id: input.productId, ...input, + // Attribute ownership to the creator (unless explicitly supplied) so it + // shows up under "my projects". + ownerId: input.ownerId ?? auth.sub, createdAt: now, updatedAt: now, }; diff --git a/services/platform-service/src/modules/products/types.ts b/services/platform-service/src/modules/products/types.ts index 25d0c5ca..3a213bc7 100644 --- a/services/platform-service/src/modules/products/types.ts +++ b/services/platform-service/src/modules/products/types.ts @@ -109,6 +109,10 @@ export interface ProductDoc { websiteUrl: string; status: ProductStatus; prelaunchConfig?: PrelaunchConfig; + /** The user (JWT `sub`) who created/registered this product. Drives per-user + * scoping ("my projects") for a generic, multi-tenant platform. Optional for + * backward compatibility with products created before ownership tracking. */ + ownerId?: string; createdAt: string; updatedAt: string; } @@ -138,6 +142,7 @@ export const CreateProductSchema = z.object({ websiteUrl: z.string().url().or(z.literal('')).default(''), status: z.enum(PRODUCT_STATUSES).default('active'), prelaunchConfig: PrelaunchConfigSchema.optional(), + ownerId: z.string().optional(), }); export const UpdateProductSchema = z.object({