Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"fastify-raw-body": "^5.0.0",
"fastify-zod-openapi": "^5.6.1",
"groupmq": "catalog:",
"jose": "^6.2.3",
"jsonwebtoken": "^9.0.2",
"pino": "catalog:",
"pino-pretty": "catalog:",
Expand Down
123 changes: 123 additions & 0 deletions apps/api/src/controllers/manage.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ import { z } from 'zod';
import { HttpError } from '@/utils/errors';

// Validation schemas (exported for use in router)
export const zCreateOrganization = z.object({
name: z.string().min(1),
timezone: z.string().optional(),
});

export const zUpdateOrganization = z.object({
name: z.string().min(1).optional(),
timezone: z.string().optional(),
});

export const zCreateProject = z.object({
name: z.string().min(1),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
Expand Down Expand Up @@ -238,6 +248,119 @@ export async function deleteProject(
reply.send({ success: true });
}

// ---------------------------------------------------------------------
// Organizations CRUD
//
// Available to /manage callers authenticated via OIDC JWT
// (`platform-admin`-class roles, see apps/api/src/utils/auth.ts) and to
// root-Client callers for read/update/delete of their own organization.
// Creating *new* organizations is realistically only useful to a
// platform-admin caller — root Clients are scoped to one org and can't
// create siblings — but the endpoint doesn't enforce that gate; it
// trusts that whoever has admin auth is permitted by the operator.
// ---------------------------------------------------------------------

export async function listOrganizations(
request: FastifyRequest,
reply: FastifyReply
) {
// For now, callers see only the org their auth scope is bound to.
// A platform-admin JWT scoped at the instance level would warrant
// returning every Organization, but that requires a richer claim
// model than v1 ships with.
const org = await db.organization.findFirst({
where: { id: request.client!.organizationId },
});
reply.send({ data: org ? [org] : [] });
}

export async function getOrganization(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const org = await db.organization.findFirst({
where: {
id: request.params.id,
// Same-org scoping. JWT-auth callers can only `get` the org
// their claim is bound to; cross-org reads require additional
// claim plumbing we haven't designed yet.
...(request.client!.organizationId
? { id: request.client!.organizationId }
: {}),
},
});
if (!org) {
throw new HttpError('Organization not found', { status: 404 });
}
reply.send({ data: org });
Comment on lines +277 to +295
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Gate organization reads and mutations on request.params.id === request.client!.organizationId.

getOrganization currently overwrites request.params.id with request.client!.organizationId, so /organizations/other-id can return the caller's own org instead of a 404. updateOrganization and deleteOrganization skip that check entirely, which lets any authenticated manage caller update or delete another organization by ID.

🛡️ Suggested guard
 export async function getOrganization(
   request: FastifyRequest<{ Params: { id: string } }>,
   reply: FastifyReply
 ) {
+  if (request.params.id !== request.client!.organizationId) {
+    throw new HttpError('Organization not found', { status: 404 });
+  }
   const org = await db.organization.findFirst({
-    where: {
-      id: request.params.id,
-      ...(request.client!.organizationId
-        ? { id: request.client!.organizationId }
-        : {}),
-    },
+    where: { id: request.params.id },
   });
 export async function updateOrganization(...) {
+  if (request.params.id !== request.client!.organizationId) {
+    throw new HttpError('Organization not found', { status: 404 });
+  }
   const existing = await db.organization.findFirst({
     where: { id: request.params.id },
   });
 export async function deleteOrganization(...) {
+  if (request.params.id !== request.client!.organizationId) {
+    throw new HttpError('Organization not found', { status: 404 });
+  }
   const existing = await db.organization.findFirst({
     where: { id: request.params.id },
   });

Also applies to: 320-360

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/controllers/manage.controller.ts` around lines 277 - 295, The
handlers are incorrectly allowing cross-org access by overwriting or ignoring
request.params.id; update getOrganization to enforce that request.params.id must
equal request.client!.organizationId (do not replace params.id with the client
org id) and return a 403/404 when they differ, and apply the same strict
equality guard to updateOrganization and deleteOrganization (ensure both
functions check request.params.id === request.client!.organizationId before
performing DB reads/updates/deletes and bail out if not equal). Use the unique
symbols getOrganization, updateOrganization, deleteOrganization,
request.params.id, and request.client!.organizationId to locate and add the
guard logic so callers cannot act on other organizations.

}

export async function createOrganization(
request: FastifyRequest<{ Body: z.infer<typeof zCreateOrganization> }>,
reply: FastifyReply
) {
const { name, timezone } = request.body;

// No createdByUserId on this code path — Organization.createdByUserId
// is nullable and the relation is SetNull on delete. JWT-auth admins
// and root Clients are not Users; we leave the field unset so the
// newly-created Org has no human owner.
const org = await db.organization.create({
data: {
id: await getId('organization', name),
name,
timezone: timezone ?? null,
onboarding: 'completed',
},
});

reply.send({ data: org });
}

export async function updateOrganization(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateOrganization>;
}>,
reply: FastifyReply
) {
const existing = await db.organization.findFirst({
where: { id: request.params.id },
});
if (!existing) {
throw new HttpError('Organization not found', { status: 404 });
}

const data: { name?: string; timezone?: string | null } = {};
if (request.body.name !== undefined) data.name = request.body.name;
if (request.body.timezone !== undefined) {
data.timezone = request.body.timezone;
}

const org = await db.organization.update({
where: { id: request.params.id },
data,
});
reply.send({ data: org });
}

export async function deleteOrganization(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const existing = await db.organization.findFirst({
where: { id: request.params.id },
});
if (!existing) {
throw new HttpError('Organization not found', { status: 404 });
}

await db.organization.delete({
where: { id: request.params.id },
});
reply.send({ success: true });
}

// Clients CRUD
export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
Expand Down
42 changes: 40 additions & 2 deletions apps/api/src/routes/manage.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import * as controller from '@/controllers/manage.controller';
import { listDashboards, listReports } from '@/controllers/insights.controller';
import {
zCreateClient,
zCreateOrganization,
zCreateProject,
zCreateReference,
zUpdateClient,
zUpdateOrganization,
zUpdateProject,
zUpdateReference,
} from '@/controllers/manage.controller';
import { validateManageRequest } from '@/utils/auth';
import { validateAdminRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';

const idParam = z.object({ id: z.string() });
Expand All @@ -26,7 +28,7 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => {

fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
try {
const client = await validateManageRequest(req.headers);
const client = await validateAdminRequest(req.headers);
req.client = client;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
Expand Down Expand Up @@ -64,6 +66,42 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => {
}
});

// Organizations routes
fastify.route({
method: 'GET',
url: '/organizations',
schema: { tags: ['Manage'], description: 'List organizations the caller has access to.' },
handler: controller.listOrganizations,
});

fastify.route({
method: 'GET',
url: '/organizations/:id',
schema: { params: idParam, tags: ['Manage'], description: 'Get an organization by ID.' },
handler: controller.getOrganization,
});

fastify.route({
method: 'POST',
url: '/organizations',
schema: { body: zCreateOrganization, tags: ['Manage'], description: 'Create a new organization. Typically called by platform-admin OIDC-authenticated callers.' },
handler: controller.createOrganization,
});

fastify.route({
method: 'PATCH',
url: '/organizations/:id',
schema: { params: idParam, body: zUpdateOrganization, tags: ['Manage'], description: 'Update an organization (name, timezone).' },
handler: controller.updateOrganization,
});

fastify.route({
method: 'DELETE',
url: '/organizations/:id',
schema: { params: idParam, tags: ['Manage'], description: 'Delete an organization and cascade its projects, clients, and members.' },
handler: controller.deleteOrganization,
});

// Projects routes
fastify.route({
method: 'GET',
Expand Down
Loading